diff --git a/.env.example b/.env.example index dd95e80fa1..4796b1018f 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,30 @@ NUXT_SESSION_PASSWORD="" # HMAC secret for image-proxy and OG image URL signing, can use `openssl rand -hex 32` NUXT_IMAGE_PROXY_SECRET="" + +# Socket (socket.dev) security data source - optional. +# Without these, the Socket source reports itself as unavailable and the UI +# shows its checkbox as "unavailable on this deployment"; OSV still works. +# +# To obtain a key: sign in at https://socket.dev, then in the dashboard go to +# Settings -> API Tokens -> "+ Create API token" and grant it the +# `packages:list` scope (docs: https://docs.socket.dev/reference/creating-and-managing-api-tokens). +# That is the only scope needed - it is what the batch-purl endpoint npmx uses +# requires. The `alerts:list`/`alerts:trend` scopes are for a different (org +# alerts feed) endpoint and do NOT grant access here. +# Quota note: that endpoint costs a flat 100 units per request (up to 1024 +# packages), so a default 500-units/hour token allows only ~5 fresh scans an +# hour. To stretch that, npmx caches Socket findings per package@version for a +# day (a published version is immutable), so overlapping and revisited +# dependency trees are served from cache and only never-before-seen versions +# cost a request; a quota/auth rejection also trips a short cooldown rather +# than hammering the API. +# NUXT_SOCKET_ORG_SLUG is your organization's slug (the `orgs/` segment +# in the dashboard URL). +NUXT_SOCKET_API_KEY="" +NUXT_SOCKET_ORG_SLUG="" +# Prefer providing the credentials above at build time: the public +# availability flag is derived during the build, and prerendered pages (like +# the settings page) bake it in. If the credentials only exist at runtime, +# also set this so server-rendered pages report Socket as available: +# NUXT_PUBLIC_SOCKET_CONFIGURED="true" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9eb942b099..18ff24fbb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,6 +113,15 @@ Please use the version pinned in the `engines.node` and `packageManager` field o pnpm npmx-connector ``` +5. (optional) to work on the Socket security data source, copy `.env.example` to `.env` and set your own Socket credentials: + + - `NUXT_SOCKET_API_KEY` — create an organization API token at [socket.dev](https://socket.dev) under **Settings → API Tokens → "+ Create API token"**, granting it the `packages:list` scope (see [Socket's token docs](https://docs.socket.dev/reference/creating-and-managing-api-tokens)). That single scope is what the batch-purl endpoint npmx uses requires; the `alerts:list`/`alerts:trend` scopes are for a different (org alerts feed) endpoint and do **not** grant access here. + - `NUXT_SOCKET_ORG_SLUG` — your organization's slug (the `orgs/` segment in the dashboard URL). + + The key stays server-side (it is never sent to the browser). Without these, the Socket source simply reports itself as unavailable — everything else, including OSV-based vulnerability scanning, works normally. + + > **Quota:** the batch-purl endpoint costs a flat 100 units per request (up to 1024 packages), so a default 500-units/hour token only covers ~5 fresh scans an hour. npmx caches Socket findings per `package@version` for a day (a published version is immutable), so overlapping and revisited trees are served from cache and only never-before-seen versions cost a request; a quota/auth rejection also trips a short cooldown, so an exhausted token degrades to OSV-only rather than erroring repeatedly. + ## Development workflow ### Available commands diff --git a/app/assets/logos/security-sources/osv-mark-dark.svg b/app/assets/logos/security-sources/osv-mark-dark.svg new file mode 100644 index 0000000000..a411c2524e --- /dev/null +++ b/app/assets/logos/security-sources/osv-mark-dark.svg @@ -0,0 +1 @@ + diff --git a/app/assets/logos/security-sources/osv-mark-light.svg b/app/assets/logos/security-sources/osv-mark-light.svg new file mode 100644 index 0000000000..898952fb8e --- /dev/null +++ b/app/assets/logos/security-sources/osv-mark-light.svg @@ -0,0 +1 @@ + diff --git a/app/assets/logos/security-sources/socket.svg b/app/assets/logos/security-sources/socket.svg new file mode 100644 index 0000000000..7795b4aee6 --- /dev/null +++ b/app/assets/logos/security-sources/socket.svg @@ -0,0 +1 @@ + diff --git a/app/components/Compare/FacetScatterChart.vue b/app/components/Compare/FacetScatterChart.vue index 9c67a5b760..ddc8a4f31c 100644 --- a/app/components/Compare/FacetScatterChart.vue +++ b/app/components/Compare/FacetScatterChart.vue @@ -55,6 +55,7 @@ watch( const isDarkMode = computed(() => resolvedMode.value === 'dark') const { facetLabels } = useFacetSelection() +const { anySourceEnabled: anySecuritySourceEnabled } = useSecuritySources() const chartableFacets = computed(() => ( @@ -69,12 +70,26 @@ const chartableFacets = computed(() => description: facet.description, chartable: facet.chartable_scatter, })) - .filter(facet => facet.chartable), + .filter(facet => facet.chartable) + // Security counts are meaningless without an enabled security source + .filter( + facet => + (facet.name !== 'vulnerabilities' && facet.name !== 'supplyChainAlerts') || + anySecuritySourceEnabled.value, + ), ) const selectedFacetX = ref('downloads') const selectedFacetY = ref('installSize') +// If the selected axis facet becomes unavailable (e.g. all security sources +// were disabled), fall back to the defaults +watchEffect(() => { + const available = new Set(chartableFacets.value.map(facet => facet.name)) + if (!available.has(selectedFacetX.value)) selectedFacetX.value = 'downloads' + if (!available.has(selectedFacetY.value)) selectedFacetY.value = 'installSize' +}) + const dataset = computed(() => buildCompareScatterChartDataset( props.packagesData, diff --git a/app/components/Package/Dependencies.vue b/app/components/Package/Dependencies.vue index 649bfb34a5..f9f26329e2 100644 --- a/app/components/Package/Dependencies.vue +++ b/app/components/Package/Dependencies.vue @@ -25,13 +25,24 @@ const { data: vulnTree } = useDependencyAnalysis( () => props.version, ) +// Only show findings from enabled security data sources +const { effectiveSources } = useSecuritySources() +const displayVulnTree = computed(() => { + if (!vulnTree.value) return null + return filterVulnerabilityTreeBySources(vulnTree.value, effectiveSources.value) +}) + // Check if a dependency has vulnerabilities (only direct deps) function getVulnerableDepInfo(depName: string) { - if (!vulnTree.value) return null - return vulnTree.value.vulnerablePackages.find(p => p.name === depName && p.depth === 'direct') + if (!displayVulnTree.value) return null + return displayVulnTree.value.vulnerablePackages.find( + p => p.name === depName && p.depth === 'direct', + ) } // Check if a dependency is deprecated (only direct deps) +// Note: deprecation comes from npm packument data, not from security sources, +// so it is intentionally not gated by the security-source preference function getDeprecatedDepInfo(depName: string) { if (!vulnTree.value) return null return vulnTree.value.deprecatedPackages.find(p => p.name === depName && p.depth === 'direct') diff --git a/app/components/Package/SupplyChainAlerts.vue b/app/components/Package/SupplyChainAlerts.vue new file mode 100644 index 0000000000..438a0e0c69 --- /dev/null +++ b/app/components/Package/SupplyChainAlerts.vue @@ -0,0 +1,215 @@ + + + diff --git a/app/components/Package/VulnerabilityTree.vue b/app/components/Package/VulnerabilityTree.vue index bca8eb312d..57ba157efa 100644 --- a/app/components/Package/VulnerabilityTree.vue +++ b/app/components/Package/VulnerabilityTree.vue @@ -9,17 +9,36 @@ const { data: vulnTree, status } = useDependencyAnalysis( () => props.version, ) +const { effectiveSources, anySourceEnabled } = useSecuritySources() + +const displayTree = computed(() => { + if (!vulnTree.value) return null + return filterVulnerabilityTreeBySources(vulnTree.value, effectiveSources.value) +}) + const isExpanded = shallowRef(false) const showAllVulnerabilities = shallowRef(false) +/** Findings shown per package before "show more" is clicked */ +const VISIBLE_VULNERABILITIES = 2 + const { visibleItems: visiblePackages, hasMore: hasMorePackages, expand: expandPackages, -} = useVisibleItems(() => vulnTree.value?.vulnerablePackages ?? [], 5) +} = useVisibleItems(() => displayTree.value?.vulnerablePackages ?? [], 5) const hasVulnerabilities = computed( - () => vulnTree.value && vulnTree.value.vulnerablePackages.length > 0, + () => displayTree.value && displayTree.value.vulnerablePackages.length > 0, +) + +// A "successful" response where no ENABLED source produced data contains no +// vulnerability info for this user - treat it like a scan failure rather +// than a clean result (an 'ok' status on a disabled source must not count) +const allSourcesFailed = computed( + () => + !!vulnTree.value && + noEnabledSecuritySourceHasData(vulnTree.value.sourceStatus, effectiveSources.value), ) // Banner - amber for better light mode contrast @@ -30,15 +49,74 @@ const severityLabels = computed(() => ({ high: $t('package.vulnerabilities.severity.high'), moderate: $t('package.vulnerabilities.severity.moderate'), low: $t('package.vulnerabilities.severity.low'), + unknown: $t('package.vulnerabilities.severity.unknown'), +})) + +/** + * Alias lists on merged entries come from the OSV record (Socket contributes + * only its one-to-one cveId pairing), so disputed aliases are attributed to + * OSV whenever it is among the entry's sources. + */ +function aliasDisputeText(vuln: VulnerabilitySummary): string { + const source: SecuritySourceId = vuln.sources.includes('osv') ? 'osv' : 'socket' + return $t('package.vulnerabilities.alias_dispute', { + source: sourceLabels.value[source], + ids: (vuln.disputedAliases ?? []).join(', '), + }) +} + +/** + * Advisory ids to display for a finding: the GHSA and CVE ids, each linking + * to its registry. Alias groups (e.g. OSV's) can join distinct advisories, + * cross-listing each other's ids - so an aliased id is only shown when it is + * unambiguous (a single candidate) or a source explicitly paired it (cveId). + * Synthetic ids (SOCKET-*) are internal dedup keys, never shown. + */ +function displayIds(vuln: VulnerabilitySummary): Array<{ label: string; url: string }> { + const ids: Array<{ label: string; url: string }> = [] + const ghsaAliases = vuln.aliases.filter(alias => alias.startsWith('GHSA-')) + const cveAliases = vuln.aliases.filter(alias => alias.startsWith('CVE-')) + const ghsa = vuln.id.startsWith('GHSA-') + ? vuln.id + : ghsaAliases.length === 1 + ? ghsaAliases[0] + : undefined + const cve = vuln.id.startsWith('CVE-') + ? vuln.id + : (vuln.cveId ?? (cveAliases.length === 1 ? cveAliases[0] : undefined)) + if (ghsa) ids.push({ label: ghsa, url: `https://github.com/advisories/${ghsa}` }) + if (cve) ids.push({ label: cve, url: `https://nvd.nist.gov/vuln/detail/${cve}` }) + if (ids.length === 0 && !vuln.id.startsWith('SOCKET-')) { + ids.push({ label: vuln.id, url: vuln.url }) + } + return ids +} + +const sourceLabels = computed>(() => ({ + osv: $t('settings.security_sources.osv'), + socket: $t('settings.security_sources.socket'), +})) + +const reachabilityLabels = computed>(() => ({ + reachable: $t('package.vulnerabilities.reachability.reachable'), + maybe_reachable: $t('package.vulnerabilities.reachability.maybe_reachable'), + unreachable: $t('package.vulnerabilities.reachability.unreachable'), })) +// Reachability is Socket's headline signal: color it by how actionable it is +const reachabilityColors: Record = { + reachable: 'text-red-700 dark:text-red-400 border-red-500/40', + maybe_reachable: 'text-amber-700 dark:text-amber-400 border-amber-500/40', + unreachable: 'text-emerald-700 dark:text-emerald-400 border-emerald-500/40', +} + function getPackageSeverityLabel(severity: Exclude) { return severityLabels.value[severity] } const summaryText = computed(() => { - if (!vulnTree.value) return '' - const { totalCounts } = vulnTree.value + if (!displayTree.value) return '' + const { totalCounts } = displayTree.value return SEVERITY_LEVELS.filter(s => totalCounts[s] > 0) .map(s => `${totalCounts[s]} ${getPackageSeverityLabel(s)}`) .join(', ') @@ -70,8 +148,13 @@ function getDepthStyle(depth: string | undefined) {