diff --git a/CHANGELOG.md b/CHANGELOG.md index 0098a700a..4999fd836 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 6cb9873c6..304a9bf0b 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 65f0617f6..c4b975be4 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 b0f42b3a8..a23a5b776 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 98d7bc032..5c8aa343a 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 a36426b76..2213c1f44 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 4917a96ce..c333e0014 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 cdefff6bb..c1df7c33a 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 e36118644..99bea030a 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 1c466eae5..cdb7cf03a 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 6a86ff950..40fb08415 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 dc9a6d142..cb32a170a 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 34102dd43..bbb8a4009 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 c5b255cbc..e9d87734c 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 000000000..672cf15b3 --- /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 000000000..f4cdd5251 --- /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 2b598e1ac..2a09f52e0 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 74be27e83..afdbbfea2 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.