From 98358001c4855c069f329652caa07236246e616a Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Thu, 22 Oct 2026 15:05:41 +0200 Subject: [PATCH 1/2] feat(scan): scope reachability analysis to chosen advisories `--reach-advisories` on `socket scan create --reach` and `socket scan reach` takes GHSA and CVE IDs. CVE IDs are converted to GHSA IDs through the GitHub API, the same way `socket fix --id` does, and an ID that cannot be resolved fails the command. The GHSA IDs go to Coana as `--ghsas`, which only analyzes those advisories and gives every other one unknown reachability. --- CHANGELOG.md | 5 ++ src/commands/ci/handle-ci.mts | 1 + src/commands/fix/handle-fix.mts | 9 +-- src/commands/scan/cmd-scan-create.mts | 11 ++++ src/commands/scan/cmd-scan-create.test.mts | 1 + src/commands/scan/cmd-scan-reach.mts | 5 ++ src/commands/scan/cmd-scan-reach.test.mts | 1 + src/commands/scan/create-scan-from-github.mts | 1 + src/commands/scan/exclude-paths.test.mts | 1 + .../generate-uv-member-sboms.e2e.test.mts | 1 + .../scan/handle-create-new-scan.test.mts | 6 ++ src/commands/scan/handle-scan-reach.test.mts | 8 +++ .../scan/perform-reachability-analysis.mts | 5 ++ .../perform-reachability-analysis.test.mts | 19 +++++- src/commands/scan/reach-advisories.mts | 56 +++++++++++++++++ src/commands/scan/reach-advisories.test.mts | 62 +++++++++++++++++++ src/commands/scan/reachability-flags.mts | 6 ++ src/utils/cve-to-ghsa.mts | 4 ++ 18 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 src/commands/scan/reach-advisories.mts create mode 100644 src/commands/scan/reach-advisories.test.mts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0098a700ad..4999fd836a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added +- `socket scan create --reach` and `socket scan reach` take `--reach-advisories` to focus reachability analysis on the GHSA or CVE advisories you care about. Every other advisory is left with unknown reachability. + ## [1.10.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.0) - 2026-10-22 ### Added diff --git a/src/commands/ci/handle-ci.mts b/src/commands/ci/handle-ci.mts index 6cb9873c6b..304a9bf0b0 100644 --- a/src/commands/ci/handle-ci.mts +++ b/src/commands/ci/handle-ci.mts @@ -67,6 +67,7 @@ export async function handleCi(autoManifest: boolean): Promise { reach: { dynamicSbomInference: false, excludePaths: [], + reachAdvisories: [], reachAnalysisMemoryLimit: '', reachAnalysisTimeout: '', reachConcurrency: 1, diff --git a/src/commands/fix/handle-fix.mts b/src/commands/fix/handle-fix.mts index 65f0617f62..c4b975be48 100644 --- a/src/commands/fix/handle-fix.mts +++ b/src/commands/fix/handle-fix.mts @@ -4,16 +4,17 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { coanaFix } from './coana-fix.mts' import { outputFixResult } from './output-fix-result.mts' -import { convertCveToGhsa } from '../../utils/cve-to-ghsa.mts' +import { + CVE_FORMAT_REGEXP, + GHSA_FORMAT_REGEXP, + convertCveToGhsa, +} from '../../utils/cve-to-ghsa.mts' import { convertPurlToGhsas } from '../../utils/purl-to-ghsa.mts' import type { FixConfig } from './types.mts' import type { OutputKind } from '../../types.mts' import type { Remap } from '@socketsecurity/registry/lib/objects' -const GHSA_FORMAT_REGEXP = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/ -const CVE_FORMAT_REGEXP = /^CVE-\d{4}-\d{4,}$/ - export type HandleFixConfig = Remap< FixConfig & { applyFixes: boolean diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index b0f42b3a8b..a23a5b776d 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -10,6 +10,7 @@ import { } from './generate-uv-member-sboms.mts' import { handleCreateNewScan } from './handle-create-new-scan.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' +import { resolveReachAdvisories } from './reach-advisories.mts' import { DYNAMIC_SBOM_INFERENCE_DESCRIPTION, excludePathsFlag, @@ -523,7 +524,11 @@ async function run( const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) + const reachAdvisoriesRaw = cmdFlagValueToArray(cli.flags['reachAdvisories']) + // Validation helpers for better readability. + const hasReachAdvisories = reachAdvisoriesRaw.length > 0 + const hasReachEcosystems = reachEcosystems.length > 0 const hasReachExcludePaths = reachExcludePaths.length > 0 @@ -559,6 +564,7 @@ async function run( ) const isUsingAnyReachabilityFlags = + hasReachAdvisories || hasReachEcosystems || hasReachExcludePaths || isUsingAnyBooleanReachFlag || @@ -666,6 +672,10 @@ async function run( return } + const reachAdvisories = reach + ? await resolveReachAdvisories(reachAdvisoriesRaw) + : [] + await handleCreateNewScan({ autoManifest: Boolean(autoManifest), branchName: branchName as string, @@ -685,6 +695,7 @@ async function run( reach: { dynamicSbomInference: Boolean(dynamicSbomInference), excludePaths, + reachAdvisories, reachAnalysisMemoryLimit, reachAnalysisTimeout, reachConcurrency: Number(reachConcurrency), diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 98d7bc0320..5c8aa343a4 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -122,6 +122,7 @@ describe('socket scan create', async () => { --workspace The workspace in the Socket Organization that the repository is in to associate with the full scan. Reachability Options (when --reach is used) + --reach-advisories List of advisories to run reachability analysis for, as GHSA or CVE IDs, either as a comma separated value or as multiple flags. CVE IDs are converted to GHSA IDs through the GitHub API. Vulnerabilities from other advisories get unknown reachability. Defaults to all advisories. --reach-analysis-memory-limit The maximum memory for the reachability analysis as a whole number optionally followed by MB or GB (e.g. 512MB, 8GB). The default is 8GB. --reach-analysis-timeout Set the timeout for the reachability analysis as a whole number optionally followed by s, m or h (e.g. 90s, 10m, 1h). Defaults to 10m. Split analysis runs may cause the total scan time to exceed this timeout significantly. --reach-concurrency Set the maximum number of concurrent reachability analysis runs. It is recommended to choose a concurrency level that ensures each analysis run has at least the --reach-analysis-memory-limit amount of memory available. diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index a36426b76e..2213c1f44b 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -4,6 +4,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { assertValidExcludePaths } from './exclude-paths.mts' import { handleScanReach } from './handle-scan-reach.mts' +import { resolveReachAdvisories } from './reach-advisories.mts' import { DYNAMIC_SBOM_INFERENCE_DESCRIPTION, excludePathsFlag, @@ -200,6 +201,7 @@ async function run( // Process comma-separated values for isMultiple flags. const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + const reachAdvisoriesRaw = cmdFlagValueToArray(cli.flags['reachAdvisories']) const reachEcosystemsRaw = cmdFlagValueToArray(cli.flags['reachEcosystems']) const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) assertValidExcludePaths(excludePaths) @@ -290,6 +292,8 @@ async function run( return } + const reachAdvisories = await resolveReachAdvisories(reachAdvisoriesRaw) + await handleScanReach({ cwd, interactive, @@ -299,6 +303,7 @@ async function run( reachabilityOptions: { dynamicSbomInference: Boolean(dynamicSbomInference), excludePaths, + reachAdvisories, reachAnalysisMemoryLimit, reachAnalysisTimeout, reachConcurrency: Number(reachConcurrency), diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 4917a96ce8..c333e0014f 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -39,6 +39,7 @@ describe('socket scan reach', async () => { Reachability Options --dynamic-sbom-inference For Gradle, sbt, and Maven: generate a Socket facts SBOM (produced directly by each package manager) per independent build root, instead of one synthetic root. The reachability analysis is split per project/module accordingly. --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --reach-advisories List of advisories to run reachability analysis for, as GHSA or CVE IDs, either as a comma separated value or as multiple flags. CVE IDs are converted to GHSA IDs through the GitHub API. Vulnerabilities from other advisories get unknown reachability. Defaults to all advisories. --reach-analysis-memory-limit The maximum memory for the reachability analysis as a whole number optionally followed by MB or GB (e.g. 512MB, 8GB). The default is 8GB. --reach-analysis-timeout Set the timeout for the reachability analysis as a whole number optionally followed by s, m or h (e.g. 90s, 10m, 1h). Defaults to 10m. Split analysis runs may cause the total scan time to exceed this timeout significantly. --reach-concurrency Set the maximum number of concurrent reachability analysis runs. It is recommended to choose a concurrency level that ensures each analysis run has at least the --reach-analysis-memory-limit amount of memory available. diff --git a/src/commands/scan/create-scan-from-github.mts b/src/commands/scan/create-scan-from-github.mts index cdefff6bb2..c1df7c33af 100644 --- a/src/commands/scan/create-scan-from-github.mts +++ b/src/commands/scan/create-scan-from-github.mts @@ -325,6 +325,7 @@ async function scanOneRepo( reach: { dynamicSbomInference: false, excludePaths: [], + reachAdvisories: [], reachAnalysisMemoryLimit: '', reachAnalysisTimeout: '', reachConcurrency: 1, diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts index e36118644c..99bea030ad 100644 --- a/src/commands/scan/exclude-paths.test.mts +++ b/src/commands/scan/exclude-paths.test.mts @@ -16,6 +16,7 @@ function makeReachOptions( return { dynamicSbomInference: false, excludePaths: [], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, diff --git a/src/commands/scan/generate-uv-member-sboms.e2e.test.mts b/src/commands/scan/generate-uv-member-sboms.e2e.test.mts index 1c466eae57..cdb7cf03af 100644 --- a/src/commands/scan/generate-uv-member-sboms.e2e.test.mts +++ b/src/commands/scan/generate-uv-member-sboms.e2e.test.mts @@ -232,6 +232,7 @@ describe('uv member scans with the real uv binary', { timeout: 30_000 }, () => { reach: { dynamicSbomInference: false, excludePaths: [], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 6a86ff9502..40fb084154 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -105,6 +105,7 @@ function createConfig( reach: { dynamicSbomInference: false, excludePaths: [], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -563,6 +564,7 @@ describe('handleCreateNewScan excludePaths', () => { reach: { dynamicSbomInference: false, excludePaths: ['tests', 'packages/*'], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -627,6 +629,7 @@ describe('handleCreateNewScan excludePaths', () => { reach: { dynamicSbomInference: false, excludePaths: ['apps/api/tests', '**/dist'], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -697,6 +700,7 @@ describe('handleCreateNewScan excludePaths', () => { reach: { dynamicSbomInference: false, excludePaths: ['tests'], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -757,6 +761,7 @@ describe('handleCreateNewScan excludePaths', () => { reach: { dynamicSbomInference: false, excludePaths: ['apps/api'], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -817,6 +822,7 @@ describe('handleCreateNewScan excludePaths', () => { reach: { dynamicSbomInference: false, excludePaths: ['tests'], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts index dc9a6d142a..cb32a170ae 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -119,6 +119,7 @@ describe('handleScanReach', () => { const reachabilityOptions = { dynamicSbomInference: false, excludePaths: ['tests', 'packages/*'], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -171,6 +172,7 @@ describe('handleScanReach', () => { const reachabilityOptions = { dynamicSbomInference: false, excludePaths: ['apps/api/tests', '**/dist'], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -233,6 +235,7 @@ describe('handleScanReach', () => { const reachabilityOptions = { dynamicSbomInference: false, excludePaths: ['apps/api'], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -281,6 +284,7 @@ describe('handleScanReach', () => { const reachabilityOptions = { dynamicSbomInference: false, excludePaths: ['tests'], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -333,6 +337,7 @@ describe('handleScanReach', () => { const reachabilityOptions = { dynamicSbomInference: false, excludePaths: [], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -370,6 +375,7 @@ describe('handleScanReach', () => { const reachabilityOptions = { dynamicSbomInference: false, excludePaths: [], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -420,6 +426,7 @@ describe('handleScanReach', () => { const reachabilityOptions = { dynamicSbomInference: false, excludePaths: [], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, @@ -473,6 +480,7 @@ describe('handleScanReach', () => { const baseReachabilityOptions = { dynamicSbomInference: true, excludePaths: ['vendor/**'], + reachAdvisories: [], reachAnalysisMemoryLimit: '8192', reachAnalysisTimeout: '', reachConcurrency: 1, diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 34102dd43e..bbb8a40092 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -28,6 +28,8 @@ import type { StdioOptions } from 'node:child_process' export type ReachabilityOptions = { dynamicSbomInference: boolean excludePaths: string[] + // GHSA IDs. Empty means all advisories. + reachAdvisories: string[] reachAnalysisMemoryLimit: string reachAnalysisTimeout: string reachConcurrency: number @@ -254,6 +256,9 @@ export async function performReachabilityAnalysis( ...(reachabilityOptions.reachEcosystems.length ? ['--purl-types', ...reachabilityOptions.reachEcosystems] : []), + ...(reachabilityOptions.reachAdvisories.length + ? ['--ghsas', ...reachabilityOptions.reachAdvisories] + : []), ...(reachabilityOptions.reachExcludePaths.length ? ['--exclude-dirs', ...reachabilityOptions.reachExcludePaths] : []), diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index c5b255cbce..e9d87734c3 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -97,6 +97,7 @@ function makeReachabilityOptions(): ReachabilityOptions { return { dynamicSbomInference: false, excludePaths: [], + reachAdvisories: [], reachAnalysisMemoryLimit: '', reachAnalysisTimeout: '', reachConcurrency: 0, @@ -266,7 +267,7 @@ describe('performReachabilityAnalysis manifests tar hash', () => { }) }) -describe('performReachabilityAnalysis timeout/memory forwarding', () => { +describe('performReachabilityAnalysis flag forwarding', () => { let scanCwd: string beforeEach(() => { @@ -325,6 +326,22 @@ describe('performReachabilityAnalysis timeout/memory forwarding', () => { expect(args).not.toContain('--analysis-timeout') expect(args).not.toContain('--memory-limit') }) + + it('passes --reach-advisories to Coana as --ghsas', async () => { + const args = await coanaArgsFor({ + reachAdvisories: ['GHSA-35jh-r3h4-6jhm', 'GHSA-r5fr-rjxr-66jc'], + }) + const ghsasIndex = args.indexOf('--ghsas') + expect(args.slice(ghsasIndex + 1, ghsasIndex + 3)).toEqual([ + 'GHSA-35jh-r3h4-6jhm', + 'GHSA-r5fr-rjxr-66jc', + ]) + }) + + it('omits --ghsas when no advisories are given', async () => { + const args = await coanaArgsFor({ reachAdvisories: [] }) + expect(args).not.toContain('--ghsas') + }) }) describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () => { diff --git a/src/commands/scan/reach-advisories.mts b/src/commands/scan/reach-advisories.mts new file mode 100644 index 0000000000..672cf15b36 --- /dev/null +++ b/src/commands/scan/reach-advisories.mts @@ -0,0 +1,56 @@ +import { arrayUnique } from '@socketsecurity/registry/lib/arrays' + +import { + CVE_FORMAT_REGEXP, + GHSA_FORMAT_REGEXP, + convertCveToGhsa, +} from '../../utils/cve-to-ghsa.mts' +import { InputError } from '../../utils/errors.mts' + +// GitHub publishes GHSA IDs with a lowercase body and CVE IDs uppercase. +function normalizeAdvisoryId(id: string): string { + const trimmed = id.trim() + return /^ghsa-/i.test(trimmed) + ? `GHSA-${trimmed.slice(5).toLowerCase()}` + : trimmed.toUpperCase() +} + +/** + * Resolves the --reach-advisories IDs to GHSA IDs, converting CVE IDs through + * the GitHub API. Any ID that cannot be resolved fails the command, since + * dropping it would leave that advisory without reachability analysis. + */ +export async function resolveReachAdvisories( + ids: readonly string[], +): Promise { + const normalizedIds = arrayUnique(ids.map(normalizeAdvisoryId)) + const invalidIds = normalizedIds.filter( + id => !GHSA_FORMAT_REGEXP.test(id) && !CVE_FORMAT_REGEXP.test(id), + ) + if (invalidIds.length) { + throw new InputError( + `Invalid --reach-advisories IDs: ${invalidIds.join(', ')}. Use GHSA IDs (GHSA-xxxx-xxxx-xxxx) or CVE IDs (CVE-YYYY-NNNN).`, + ) + } + const ghsas: string[] = [] + const errors: string[] = [] + for (const id of normalizedIds) { + if (GHSA_FORMAT_REGEXP.test(id)) { + ghsas.push(id) + continue + } + // eslint-disable-next-line no-await-in-loop + const result = await convertCveToGhsa(id) + if (result.ok) { + ghsas.push(result.data) + } else { + errors.push(`${id}: ${result.message}`) + } + } + if (errors.length) { + throw new InputError( + `Could not resolve --reach-advisories IDs to GHSA IDs:\n${errors.map(e => ` - ${e}`).join('\n')}`, + ) + } + return arrayUnique(ghsas) +} diff --git a/src/commands/scan/reach-advisories.test.mts b/src/commands/scan/reach-advisories.test.mts new file mode 100644 index 0000000000..f4cdd52514 --- /dev/null +++ b/src/commands/scan/reach-advisories.test.mts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { resolveReachAdvisories } from './reach-advisories.mts' + +const { mockConvertCveToGhsa } = vi.hoisted(() => ({ + mockConvertCveToGhsa: vi.fn(), +})) + +vi.mock('../../utils/cve-to-ghsa.mts', async importOriginal => ({ + ...(await importOriginal()), + convertCveToGhsa: mockConvertCveToGhsa, +})) + +describe('resolveReachAdvisories', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns no advisories for no IDs', async () => { + expect(await resolveReachAdvisories([])).toEqual([]) + expect(mockConvertCveToGhsa).not.toHaveBeenCalled() + }) + + it('normalizes GHSA IDs without calling the GitHub API', async () => { + expect( + await resolveReachAdvisories([ + 'GHSA-35jh-r3h4-6jhm', + ' ghsa-R5FR-RJXR-66JC ', + 'GHSA-35JH-R3H4-6JHM', + ]), + ).toEqual(['GHSA-35jh-r3h4-6jhm', 'GHSA-r5fr-rjxr-66jc']) + expect(mockConvertCveToGhsa).not.toHaveBeenCalled() + }) + + it('converts CVE IDs to GHSA IDs and drops duplicates', async () => { + mockConvertCveToGhsa.mockResolvedValue({ + ok: true, + data: 'GHSA-35jh-r3h4-6jhm', + }) + expect( + await resolveReachAdvisories(['cve-2021-23337', 'GHSA-35jh-r3h4-6jhm']), + ).toEqual(['GHSA-35jh-r3h4-6jhm']) + expect(mockConvertCveToGhsa).toHaveBeenCalledWith('CVE-2021-23337') + }) + + it('rejects IDs that are neither GHSA nor CVE IDs', async () => { + await expect( + resolveReachAdvisories(['GHSA-35jh-r3h4-6jhm', 'pkg:npm/lodash']), + ).rejects.toThrow('Invalid --reach-advisories IDs: PKG:NPM/LODASH.') + expect(mockConvertCveToGhsa).not.toHaveBeenCalled() + }) + + it('fails when a CVE ID has no GHSA', async () => { + mockConvertCveToGhsa.mockResolvedValue({ + ok: false, + message: 'No GHSA found for CVE CVE-2099-0001', + }) + await expect(resolveReachAdvisories(['CVE-2099-0001'])).rejects.toThrow( + 'CVE-2099-0001: No GHSA found for CVE CVE-2099-0001', + ) + }) +}) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index 2b598e1ac2..2a09f52e09 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -14,6 +14,12 @@ export const reachabilityFlags: MeowFlags = { type: 'string', description: `Override the version of @coana-tech/cli used for reachability analysis. Default: ${COANA_CLI_VERSION}.`, }, + reachAdvisories: { + type: 'string', + isMultiple: true, + description: + 'List of advisories to run reachability analysis for, as GHSA or CVE IDs, either as a comma separated value or as multiple flags. CVE IDs are converted to GHSA IDs through the GitHub API. Vulnerabilities from other advisories get unknown reachability. Defaults to all advisories.', + }, reachAnalysisMemoryLimit: { type: 'string', default: '8192', diff --git a/src/utils/cve-to-ghsa.mts b/src/utils/cve-to-ghsa.mts index 74be27e834..afdbbfea29 100644 --- a/src/utils/cve-to-ghsa.mts +++ b/src/utils/cve-to-ghsa.mts @@ -3,6 +3,10 @@ import { cacheFetch, getOctokit } from './github.mts' import type { CResult } from '../types.mts' +export const CVE_FORMAT_REGEXP = /^CVE-\d{4}-\d{4,}$/ + +export const GHSA_FORMAT_REGEXP = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/ + /** * Converts CVE IDs to GHSA IDs using GitHub API. * CVE to GHSA mappings are permanent, so we cache for 30 days. From 860635474d9c6f870ab60a5601ced00593fa1c66 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 26 Oct 2026 17:02:17 +0100 Subject: [PATCH 2/2] refactor: share advisory ID conversion between fix and scan reach Move the CVE/GHSA/PURL conversion loop out of `socket fix` into `src/utils/advisory-ids.mts` and have `--reach-advisories` resolution call it. `socket fix --id` keeps its behavior: case-sensitive IDs, PURL support, the same messages and logs, and sequential lookups. The reach path still normalizes case and rejects anything that is not a GHSA or CVE ID before any lookup, then throws on conversion errors. Also drop a `reach ? ... : []` guard in `scan create` that could never take its else branch, since reach flags already require `--reach`. --- src/commands/fix/handle-fix.mts | 64 ++----------------- src/commands/scan/cmd-scan-create.mts | 4 +- src/commands/scan/reach-advisories.mts | 22 ++----- src/utils/advisory-ids.mts | 88 ++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 79 deletions(-) create mode 100644 src/utils/advisory-ids.mts diff --git a/src/commands/fix/handle-fix.mts b/src/commands/fix/handle-fix.mts index c4b975be48..a21437e7e5 100644 --- a/src/commands/fix/handle-fix.mts +++ b/src/commands/fix/handle-fix.mts @@ -1,15 +1,9 @@ -import { joinAnd } from '@socketsecurity/registry/lib/arrays' import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug' import { logger } from '@socketsecurity/registry/lib/logger' import { coanaFix } from './coana-fix.mts' import { outputFixResult } from './output-fix-result.mts' -import { - CVE_FORMAT_REGEXP, - GHSA_FORMAT_REGEXP, - convertCveToGhsa, -} from '../../utils/cve-to-ghsa.mts' -import { convertPurlToGhsas } from '../../utils/purl-to-ghsa.mts' +import { resolveAdvisoryIds } from '../../utils/advisory-ids.mts' import type { FixConfig } from './types.mts' import type { OutputKind } from '../../types.mts' @@ -45,59 +39,9 @@ export async function convertIdsToGhsas( debugFn('notice', `Converting ${ids.length} IDs to GHSA format`) debugDir('inspect', { ids }) - const validGhsas: string[] = [] - const errors: string[] = [] - - for (const id of ids) { - const trimmedId = id.trim() - - if (trimmedId.startsWith('GHSA-')) { - // Already a GHSA ID, validate format - if (GHSA_FORMAT_REGEXP.test(trimmedId)) { - validGhsas.push(trimmedId) - } else { - errors.push(`Invalid GHSA format: ${trimmedId}`) - } - } else if (trimmedId.startsWith('CVE-')) { - // Convert CVE to GHSA - if (!CVE_FORMAT_REGEXP.test(trimmedId)) { - errors.push(`Invalid CVE format: ${trimmedId}`) - continue - } - - // eslint-disable-next-line no-await-in-loop - const conversionResult = await convertCveToGhsa(trimmedId) - if (conversionResult.ok) { - validGhsas.push(conversionResult.data) - if (!silence) { - logger.info(`Converted ${trimmedId} to ${conversionResult.data}`) - } - } else { - errors.push(`${trimmedId}: ${conversionResult.message}`) - } - } else if (trimmedId.startsWith('pkg:')) { - // Convert PURL to GHSAs. - // eslint-disable-next-line no-await-in-loop - const conversionResult = await convertPurlToGhsas(trimmedId) - if (conversionResult.ok && conversionResult.data.length) { - validGhsas.push(...conversionResult.data) - if (!silence) { - logger.info( - `Converted ${trimmedId} to ${conversionResult.data.length} GHSA(s): ${joinAnd(conversionResult.data)}`, - ) - } - } else { - errors.push( - `${trimmedId}: ${conversionResult.message || 'No GHSAs found'}`, - ) - } - } else { - // Neither CVE, GHSA, nor PURL, skip - errors.push( - `Unsupported ID format (expected CVE, GHSA, or PURL): ${trimmedId}`, - ) - } - } + const { errors, ghsas: validGhsas } = await resolveAdvisoryIds(ids, { + silence, + }) if (errors.length) { if (!silence) { diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index a23a5b776d..3360792450 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -672,9 +672,7 @@ async function run( return } - const reachAdvisories = reach - ? await resolveReachAdvisories(reachAdvisoriesRaw) - : [] + const reachAdvisories = await resolveReachAdvisories(reachAdvisoriesRaw) await handleCreateNewScan({ autoManifest: Boolean(autoManifest), diff --git a/src/commands/scan/reach-advisories.mts b/src/commands/scan/reach-advisories.mts index 672cf15b36..98b55587de 100644 --- a/src/commands/scan/reach-advisories.mts +++ b/src/commands/scan/reach-advisories.mts @@ -1,9 +1,9 @@ import { arrayUnique } from '@socketsecurity/registry/lib/arrays' +import { resolveAdvisoryIds } from '../../utils/advisory-ids.mts' import { CVE_FORMAT_REGEXP, GHSA_FORMAT_REGEXP, - convertCveToGhsa, } from '../../utils/cve-to-ghsa.mts' import { InputError } from '../../utils/errors.mts' @@ -24,6 +24,8 @@ export async function resolveReachAdvisories( ids: readonly string[], ): Promise { const normalizedIds = arrayUnique(ids.map(normalizeAdvisoryId)) + // Checked up front so PURLs never reach resolveAdvisoryIds and a typo fails + // before any GitHub lookup. const invalidIds = normalizedIds.filter( id => !GHSA_FORMAT_REGEXP.test(id) && !CVE_FORMAT_REGEXP.test(id), ) @@ -32,21 +34,9 @@ export async function resolveReachAdvisories( `Invalid --reach-advisories IDs: ${invalidIds.join(', ')}. Use GHSA IDs (GHSA-xxxx-xxxx-xxxx) or CVE IDs (CVE-YYYY-NNNN).`, ) } - const ghsas: string[] = [] - const errors: string[] = [] - for (const id of normalizedIds) { - if (GHSA_FORMAT_REGEXP.test(id)) { - ghsas.push(id) - continue - } - // eslint-disable-next-line no-await-in-loop - const result = await convertCveToGhsa(id) - if (result.ok) { - ghsas.push(result.data) - } else { - errors.push(`${id}: ${result.message}`) - } - } + const { errors, ghsas } = await resolveAdvisoryIds(normalizedIds, { + silence: true, + }) if (errors.length) { throw new InputError( `Could not resolve --reach-advisories IDs to GHSA IDs:\n${errors.map(e => ` - ${e}`).join('\n')}`, diff --git a/src/utils/advisory-ids.mts b/src/utils/advisory-ids.mts new file mode 100644 index 0000000000..c50425d121 --- /dev/null +++ b/src/utils/advisory-ids.mts @@ -0,0 +1,88 @@ +import { joinAnd } from '@socketsecurity/registry/lib/arrays' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { + CVE_FORMAT_REGEXP, + GHSA_FORMAT_REGEXP, + convertCveToGhsa, +} from './cve-to-ghsa.mts' +import { convertPurlToGhsas } from './purl-to-ghsa.mts' + +type ResolveAdvisoryIdsOptions = { + silence?: boolean | undefined +} + +export type ResolvedAdvisoryIds = { + errors: string[] + ghsas: string[] +} + +/** + * Converts mixed CVE/GHSA/PURL IDs to GHSA IDs, collecting one error for each + * ID that is malformed or cannot be converted. + */ +export async function resolveAdvisoryIds( + ids: readonly string[], + options?: ResolveAdvisoryIdsOptions | undefined, +): Promise { + const { silence = false } = { + __proto__: null, + ...options, + } as ResolveAdvisoryIdsOptions + + const ghsas: string[] = [] + const errors: string[] = [] + + for (const id of ids) { + const trimmedId = id.trim() + + if (trimmedId.startsWith('GHSA-')) { + // Already a GHSA ID, validate format + if (GHSA_FORMAT_REGEXP.test(trimmedId)) { + ghsas.push(trimmedId) + } else { + errors.push(`Invalid GHSA format: ${trimmedId}`) + } + } else if (trimmedId.startsWith('CVE-')) { + // Convert CVE to GHSA + if (!CVE_FORMAT_REGEXP.test(trimmedId)) { + errors.push(`Invalid CVE format: ${trimmedId}`) + continue + } + + // eslint-disable-next-line no-await-in-loop + const conversionResult = await convertCveToGhsa(trimmedId) + if (conversionResult.ok) { + ghsas.push(conversionResult.data) + if (!silence) { + logger.info(`Converted ${trimmedId} to ${conversionResult.data}`) + } + } else { + errors.push(`${trimmedId}: ${conversionResult.message}`) + } + } else if (trimmedId.startsWith('pkg:')) { + // Convert PURL to GHSAs. + // eslint-disable-next-line no-await-in-loop + const conversionResult = await convertPurlToGhsas(trimmedId) + if (conversionResult.ok && conversionResult.data.length) { + ghsas.push(...conversionResult.data) + if (!silence) { + logger.info( + `Converted ${trimmedId} to ${conversionResult.data.length} GHSA(s): ${joinAnd(conversionResult.data)}`, + ) + } + } else { + errors.push( + `${trimmedId}: ${conversionResult.message || 'No GHSAs found'}`, + ) + } + } else { + // Neither CVE, GHSA, nor PURL, skip + errors.push( + `Unsupported ID format (expected CVE, GHSA, or PURL): ${trimmedId}`, + ) + } + } + + return { errors, ghsas } +}