From 63c4e2269d9c630983bddc33c50c9dbf2a9522ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 22:03:41 +0000 Subject: [PATCH] feat: group transitive advisory aliases in scan JSON Collapse CVE, GHSA, and OSV ids that alias each other on one package, including through a third id, into one vg scan --vulns JSON finding. The primary id is the lowest CVE in the group when one is present, otherwise the lowest id by code-unit order, so a linking record that arrives last does not change the finding. SARIF still emits one result per advisory id. Fixes #302 Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- DOCS.md | 8 +- README.md | 2 + src/core-open/run-core-scan.ts | 18 +- .../scanners/vulnerability-scanner.ts | 162 +++++++- test/vuln-alias-groups.test.ts | 374 ++++++++++++++++++ 5 files changed, 553 insertions(+), 11 deletions(-) create mode 100644 test/vuln-alias-groups.test.ts diff --git a/DOCS.md b/DOCS.md index 442266d..67c50b9 100644 --- a/DOCS.md +++ b/DOCS.md @@ -2324,7 +2324,9 @@ The fail-closed path is covered by `src/reporting/commands/scan-package-manifest #### Determinism -The same tree and the same manifest produce the same findings order and the same advisory ids. JSON lists every finding by path (`location`), then rule id, then advisory id, then package URL when the finding carries an ecosystem and a package name. Alias lists on a finding are sorted. The text report prints errors, then warnings, then notes, and within a level it uses that same path order, so the terminal stays readable. SARIF keeps drift findings first. Vulnerability results then follow ecosystem, package name, and installed version, and within a package severity — critical, high, moderate, low, unknown — and then advisory id. That id is the `id` from the manifest. JSON records it as `details.advisoryId`. The order of keys in the manifest file does not change the result. `timestamp` and `durationMs` on the scan artifact still change between runs. In a git checkout, an exposure window's day count follows the scan date. The advisory id and the finding order stay the same. +The same tree and the same manifest produce the same findings order and the same advisory ids. JSON lists every finding by path (`location`), then rule id, then advisory id, then package URL when the finding carries an ecosystem and a package name. Alias lists on a finding are sorted. The text report prints errors, then warnings, then notes, and within a level it uses that same path order, so the terminal stays readable. SARIF keeps drift findings first. Vulnerability results then follow ecosystem, package name, and installed version, and within a package severity — critical, high, moderate, low, unknown — and then advisory id. That SARIF id is the `id` from the manifest. A JSON finding that did not collapse an alias group records the same id as `details.advisoryId`. The order of keys in the manifest file does not change the result. `timestamp` and `durationMs` on the scan artifact still change between runs. In a git checkout, an exposure window's day count follows the scan date. The advisory id and the finding order stay the same. + +JSON findings collapse advisories on the same package that share an id through aliases, including a chain through a third id, into one finding. The primary `details.advisoryId` is the lowest id in the group that starts with `CVE-` when any id does, compared by UTF-16 code unit; otherwise it is the lowest id. `details.aliases` lists the other ids in that same order. Which record arrives last does not change the primary id or the alias list. An advisory that no other advisory on that package links to keeps its own id. `extended.vulnerabilities` still lists each source advisory. SARIF still writes one result per advisory id. Those string keys, and the package-name tie-break on dependency rows inside a project, compare by UTF-16 code unit. SARIF results follow that finding order. Degrade-and-continue warnings are ordered by code, then message, the same way. `LANG` and `LC_ALL` do not change the JSON or the SARIF. @@ -2367,6 +2369,8 @@ Maven scopes, Maven profiles, and Gradle configurations do not all become depend SARIF from that scan is one result per package and advisory. How a GHSA, a CVE, and other aliases share that result, and when a second code-scanning alert is expected, is under [Advisories with several ids](#advisories-with-several-ids). +JSON findings (`vg scan --vulns --format json`, the text report, and `.vibgrate/scan_result.json`) collapse those linked advisories. When two or more advisories on one package name each other, or name a shared alias, including through a third id, the scan emits one finding. The primary `details.advisoryId` is the lowest `CVE-` id in that group by UTF-16 code unit when the group contains one, and otherwise the lowest id. `details.aliases` is the remaining ids, sorted the same way. Severity and the other record fields come from the advisory whose id is the primary when that advisory is present, otherwise from the lowest id. Fixing versions are the union of the group. A single advisory keeps its own id, so a CVE that appears only as its alias stays in `aliases`. The source list under `extended.vulnerabilities.packages[].advisories` is not collapsed. The same manifest produces the same finding whether the linking record is first or last. + Machine-readable JSON (`vg scan --format json`, and the `.vibgrate/scan_result.json` artifact) lists each advisory under `extended.vulnerabilities.packages[].advisories`. Match the package on `ecosystem`, `package`, and `version` — the same identity `vg sbom export` writes as a purl. See [Component identity](#component-identity). When the advisory data used for that scan already carries exploitability, the same object includes these optional fields: | Field | Type | Meaning | @@ -5079,7 +5083,7 @@ The same finding contents produce the same value. A different finding produces a #### Advisories with several ids -One advisory often has its own id plus aliases: a GHSA, a CVE, and sometimes another OSV id. SARIF writes **one result per package and advisory**. Every alias stays on that result. A second result appears only when the scan kept a second advisory id. Two advisories stay two results when one lists the other as an alias. +One advisory often has its own id plus aliases: a GHSA, a CVE, and sometimes another OSV id. SARIF writes **one result per package and advisory**. Every alias stays on that result. A second result appears only when the scan kept a second advisory id. Two advisories stay two results when one lists the other as an alias. JSON findings collapse that chain into one finding; the primary-id rule is under [Vulnerabilities and exposure attribution](#vulnerabilities-and-exposure-attribution). A near-duplicate code-scanning alert is expected in that case. If the vulnerability data returns `GHSA-bbbb` and `CVE-2024-1111` as two advisories for the same package, the file contains two results, and each names the other in `aliases`. Match them on `properties.advisoryId`. A CVE string inside the message is an alias of the primary id. diff --git a/README.md b/README.md index a03c658..787a83f 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,8 @@ One scan gives you: SARIF writes one result per package and advisory (`vg scan --vulns --format sarif`). Every result includes `partialFingerprints["vg/finding-id/v1"]`, so an unchanged finding keeps the same code-scanning alert. The advisory's own id is the primary id. CVE and other aliases stay on that result. A second code-scanning alert is expected when the scan kept two advisory ids, including when those ids list each other as aliases. Field layout, result order, and what the fingerprint is made from: [DOCS.md](./DOCS.md#advisories-with-several-ids) and [DOCS.md](./DOCS.md#result-fingerprints). +JSON writes one finding when those advisories alias each other, including through a third id. The primary id is the lowest `CVE-` id in the group when one is present, otherwise the lowest id, in code-unit order. The other ids are `aliases`, sorted the same way. An advisory that no other advisory on that package links to keeps its own id. The rule is in [DOCS.md](./DOCS.md#vulnerabilities-and-exposure-attribution). + ```bash vg scan --vulns # drift score + known vulnerabilities vg scan --full # drift + vulnerabilities + a banned-dependency report diff --git a/src/core-open/run-core-scan.ts b/src/core-open/run-core-scan.ts index ddd0a92..2eceef1 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -706,6 +706,8 @@ export async function runCoreScan( // package-version manifest (air-gapped). Off unless `--vulns` is set, so the // default scan keeps its behaviour, latency, and network surface. let vulnFindings: Finding[] = []; + // JSON collapses alias-linked advisories. SARIF keeps one result per advisory id. + let sarifVulnFindings: Finding[] = []; if (opts.vulns) { progress.insertStepBefore('drift', { id: 'vulns', label: 'Scanning for vulnerabilities', weight: 3 }); progress.startStep('vulns'); @@ -736,7 +738,8 @@ export async function runCoreScan( ) { extended.vulnerabilities = vulnerabilities; } - vulnFindings = generateVulnerabilityFindings(vulnerabilities); + sarifVulnFindings = generateVulnerabilityFindings(vulnerabilities); + vulnFindings = generateVulnerabilityFindings(vulnerabilities, { groupAliases: true }); const affected = vulnerabilities.packages.length; const transitiveAffected = vulnerabilities.packages.filter((p) => p.transitive).length; progress.completeStep( @@ -764,7 +767,9 @@ export async function runCoreScan( // ── Step: Findings ── progress.startStep('findings'); // Same package, license id, and path is one finding in text, JSON, and SARIF. - const findings = dedupeLicenseFindings([...generateFindings(allProjects, config), ...vulnFindings]); + const driftFindings = generateFindings(allProjects, config); + const findings = dedupeLicenseFindings([...driftFindings, ...vulnFindings]); + const sarifFindings = dedupeLicenseFindings([...driftFindings, ...sarifVulnFindings]); const warnCount = findings.filter((f) => f.level === 'warning').length; const errCount = findings.filter((f) => f.level === 'error').length; const noteCount = findings.filter((f) => f.level === 'note').length; @@ -916,10 +921,11 @@ export async function runCoreScan( // to it. JSON then sorts findings. SARIF keeps the pre-sort copy. artifact = portableValue(artifact, rootDir); // SARIF keeps drift findings ahead of vulnerability results, and within a - // package it keeps severity order. JSON (stdout and scan_result.json) sorts - // findings by path, rule, advisory id, and package URL so two runs agree - // even when directory walk order does not. - const sarifArtifact = artifact; + // package it keeps one result per advisory id in severity order. JSON + // (stdout and scan_result.json) collapses alias-linked advisories, then + // sorts findings by path, rule, advisory id, and package URL so two runs + // agree even when directory walk order does not. + const sarifArtifact = portableValue({ ...artifact, findings: sarifFindings }, rootDir); artifact = orderScanArtifactForEmit(artifact); if (!opts.noLocalArtifacts && !maxPrivacyMode) { diff --git a/src/core-open/scanners/vulnerability-scanner.ts b/src/core-open/scanners/vulnerability-scanner.ts index afbf5e4..4b201a6 100644 --- a/src/core-open/scanners/vulnerability-scanner.ts +++ b/src/core-open/scanners/vulnerability-scanner.ts @@ -653,11 +653,167 @@ function levelForSeverity(severity: VulnSeverity): Finding['level'] { } } -/** One finding per (package, advisory) so each CVE surfaces as its own code-scanning alert. */ -export function generateVulnerabilityFindings(result: VulnerabilityScanResult): Finding[] { +function aliasIdsOf(advisory: VulnerabilityAdvisory): string[] { + if (!Array.isArray(advisory.aliases)) return []; + const aliases: string[] = []; + for (const alias of advisory.aliases) { + if (typeof alias !== 'string' || alias.length === 0 || alias === advisory.id) continue; + if (!aliases.includes(alias)) aliases.push(alias); + } + return aliases; +} + +function cvssSortKey(value: number | null): number { + return typeof value === 'number' && Number.isFinite(value) ? value : -1; +} + +/** Stable record order. Input position is not a key. */ +function compareAdvisories(a: VulnerabilityAdvisory, b: VulnerabilityAdvisory): number { + return compareCodeUnits(a.id, b.id) + || (severityRank(b.severity) - severityRank(a.severity)) + || (cvssSortKey(b.cvss) - cvssSortKey(a.cvss)) + || compareCodeUnits(a.summary ?? '', b.summary ?? '') + || compareCodeUnits(a.fixedVersions.join('\0'), b.fixedVersions.join('\0')) + || compareCodeUnits((a.references ?? []).join('\0'), (b.references ?? []).join('\0')); +} + +/** + * Advisories on one package that name each other's ids, including through a + * third id, are one group. + * + * The link is undirected and closed over the whole set before a group is + * chosen, so a record that connects A to C still merges them when it is last. + * The primary id is the lowest `CVE-` id in the group by code-unit order when + * any id starts with `CVE-`, otherwise the lowest id. `aliases` is every + * other id in that same order. A package whose advisory does not share an id + * with another advisory on that package keeps that advisory unchanged. + * Severity and the other record fields come from the advisory whose id is + * the primary when that record is present, otherwise from the lowest id. + * Fixing versions are the union of the group, taken from advisories in the + * same record order, so a fix named on only one record is kept. + */ +export function groupAdvisoriesByAlias(advisories: readonly VulnerabilityAdvisory[]): VulnerabilityAdvisory[] { + if (advisories.length <= 1) return advisories.slice(); + + const parent = new Map(); + const rank = new Map(); + + const ensure = (id: string): void => { + if (parent.has(id)) return; + parent.set(id, id); + rank.set(id, 0); + }; + + const find = (id: string): string => { + let root = id; + while (parent.get(root) !== root) root = parent.get(root)!; + let cursor = id; + while (cursor !== root) { + const next = parent.get(cursor)!; + parent.set(cursor, root); + cursor = next; + } + return root; + }; + + const union = (left: string, right: string): void => { + let a = find(left); + let b = find(right); + if (a === b) return; + const rankA = rank.get(a) ?? 0; + const rankB = rank.get(b) ?? 0; + if (rankA < rankB) { + parent.set(a, b); + return; + } + if (rankA > rankB) { + parent.set(b, a); + return; + } + if (compareCodeUnits(a, b) > 0) { + const swap = a; + a = b; + b = swap; + } + parent.set(b, a); + rank.set(a, rankA + 1); + }; + + for (const advisory of advisories) { + if (typeof advisory.id !== 'string' || advisory.id.length === 0) continue; + ensure(advisory.id); + for (const alias of aliasIdsOf(advisory)) { + ensure(alias); + union(advisory.id, alias); + } + } + + const membersByRoot = new Map(); + const idsByRoot = new Map>(); + const addId = (root: string, id: string): void => { + const ids = idsByRoot.get(root); + if (ids) ids.add(id); + else idsByRoot.set(root, new Set([id])); + }; + for (const advisory of advisories) { + if (typeof advisory.id !== 'string' || advisory.id.length === 0) continue; + const root = find(advisory.id); + const members = membersByRoot.get(root); + if (members) members.push(advisory); + else membersByRoot.set(root, [advisory]); + addId(root, advisory.id); + for (const alias of aliasIdsOf(advisory)) addId(root, alias); + } + + const grouped: VulnerabilityAdvisory[] = []; + for (const advisory of advisories) { + if (typeof advisory.id !== 'string' || advisory.id.length === 0) grouped.push(advisory); + } + for (const root of [...membersByRoot.keys()].sort(compareCodeUnits)) { + const members = membersByRoot.get(root) ?? []; + if (members.length < 2) { + if (members[0]) grouped.push(members[0]); + continue; + } + const ids = [...(idsByRoot.get(root) ?? [])].sort(compareCodeUnits); + const primary = ids.find((id) => id.startsWith('CVE-')) ?? ids[0]; + if (!primary) continue; + const sorted = [...members].sort(compareAdvisories); + const representative = sorted.find((advisory) => advisory.id === primary) ?? sorted[0]!; + const fixedVersions: string[] = []; + for (const advisory of sorted) { + for (const version of advisory.fixedVersions) { + if (typeof version !== 'string' || version.length === 0 || fixedVersions.includes(version)) continue; + fixedVersions.push(version); + } + } + grouped.push({ + ...representative, + id: primary, + aliases: ids.filter((id) => id !== primary), + fixedVersions, + }); + } + grouped.sort((a, b) => compareCodeUnits(a.id, b.id)); + return grouped; +} + +/** + * One finding per package and advisory, so each advisory id can stay its own + * code-scanning result. + * + * `groupAliases` collapses alias-linked advisories on one package into one + * finding for JSON. The primary id rule is {@link groupAdvisoriesByAlias}. + * SARIF leaves the option off. + */ +export function generateVulnerabilityFindings( + result: VulnerabilityScanResult, + opts?: { groupAliases?: boolean }, +): Finding[] { const findings: Finding[] = []; for (const pkg of result.packages) { - for (const adv of pkg.advisories) { + const advisories = opts?.groupAliases ? groupAdvisoriesByAlias(pkg.advisories) : pkg.advisories; + for (const adv of advisories) { const cve = adv.aliases.find((a) => a.startsWith('CVE-')); const idLabel = cve && cve !== adv.id ? `${adv.id} (${cve})` : adv.id; const hint = fixAvailableHint(adv.fixedVersions); diff --git a/test/vuln-alias-groups.test.ts b/test/vuln-alias-groups.test.ts new file mode 100644 index 0000000..c99dab1 --- /dev/null +++ b/test/vuln-alias-groups.test.ts @@ -0,0 +1,374 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runCoreScan } from '../src/core-open/index.js'; +import { orderScanFindings } from '../src/core-open/findings-order.js'; +import type { PackageVersionManifest } from '../src/core-open/package-version-manifest.js'; +import { + generateVulnerabilityFindings, + groupAdvisoriesByAlias, + scanVulnerabilities, +} from '../src/core-open/scanners/vulnerability-scanner.js'; +import type { Finding, VulnerabilityAdvisory, VulnerabilityScanResult } from '../src/core-open/types.js'; +import { Semaphore } from '../src/core-open/utils/semaphore.js'; + +/** Linking record is last. A names B, C names the CVE, and B is what joins them. */ +const LODASH_LINK_LAST = [ + { + id: 'GHSA-aaaa', + aliases: ['GHSA-bbbb'], + severity: 'critical' as const, + cvss: 9.8, + ranges: [{ introduced: '0', fixed: '4.17.21' }], + }, + { + id: 'OSV-1', + aliases: ['CVE-2024-1111'], + severity: 'moderate' as const, + ranges: [{ introduced: '0' }], + }, + { + id: 'GHSA-cccc', + aliases: [] as string[], + severity: 'low' as const, + ranges: [{ introduced: '0' }], + }, + { + id: 'GHSA-bbbb', + aliases: ['OSV-1'], + severity: 'high' as const, + cvss: 7.5, + ranges: [{ introduced: '0', fixed: '4.17.22' }], + }, +]; + +/** Same advisories, linking record first. */ +const LODASH_LINK_FIRST = [ + LODASH_LINK_LAST[3]!, + LODASH_LINK_LAST[2]!, + LODASH_LINK_LAST[1]!, + LODASH_LINK_LAST[0]!, +]; + +const MS_PAIR = [ + { + id: 'OSV-ms', + aliases: ['GHSA-ms'], + severity: 'low' as const, + ranges: [{ introduced: '0' }], + }, + { + id: 'GHSA-ms', + aliases: ['OSV-ms'], + severity: 'moderate' as const, + ranges: [{ introduced: '0', fixed: '3.0.0' }], + }, +]; + +function manifest(lodashVulns: typeof LODASH_LINK_LAST): PackageVersionManifest { + return { + npm: { + lodash: { vulns: lodashVulns }, + ms: { vulns: MS_PAIR }, + 'left-pad': { + vulns: [ + { + id: 'GHSA-zzzz', + aliases: ['CVE-2024-2000'], + severity: 'moderate', + ranges: [{ introduced: '0' }], + }, + ], + }, + }, + }; +} + +const TARGETS = [ + { ecosystem: 'npm' as const, package: 'lodash', version: '4.17.20' }, + { ecosystem: 'npm' as const, package: 'ms', version: '2.1.3' }, + { ecosystem: 'npm' as const, package: 'left-pad', version: '1.0.0' }, +]; + +function advisory( + partial: Pick & Partial, +): VulnerabilityAdvisory { + return { + summary: null, + severity: 'moderate', + cvss: null, + cvssVector: null, + fixedVersions: [], + published: null, + withdrawn: null, + references: [], + ...partial, + }; +} + +function groupedJson(result: VulnerabilityScanResult): string { + return JSON.stringify(orderScanFindings(generateVulnerabilityFindings(result, { groupAliases: true }))); +} + +async function scanGrouped(doc: PackageVersionManifest, targets: typeof TARGETS): Promise { + const scanned = await scanVulnerabilities(targets, { + sem: new Semaphore(2), + offline: true, + manifest: doc, + }); + return groupedJson(scanned); +} + +describe('transitive advisory alias groups', () => { + it('picks a stable primary id and does not depend on record order', () => { + const linkedLast = [ + advisory({ id: 'GHSA-aaaa', aliases: ['GHSA-bbbb'], severity: 'critical', cvss: 9.8, fixedVersions: ['4.17.21'] }), + advisory({ id: 'OSV-1', aliases: ['CVE-2024-1111'], severity: 'moderate' }), + advisory({ id: 'GHSA-cccc', aliases: [], severity: 'low' }), + advisory({ id: 'GHSA-bbbb', aliases: ['OSV-1'], severity: 'high', cvss: 7.5, fixedVersions: ['4.17.22'] }), + ]; + const linkedFirst = [linkedLast[3]!, linkedLast[2]!, linkedLast[1]!, linkedLast[0]!]; + + const forward = groupAdvisoriesByAlias(linkedLast); + const reverse = groupAdvisoriesByAlias(linkedFirst); + expect(JSON.stringify(reverse)).toBe(JSON.stringify(forward)); + expect(forward.map((item) => item.id)).toEqual(['CVE-2024-1111', 'GHSA-cccc']); + expect(forward[0]?.aliases).toEqual(['GHSA-aaaa', 'GHSA-bbbb', 'OSV-1']); + expect(forward[0]).toMatchObject({ severity: 'critical', cvss: 9.8, fixedVersions: ['4.17.21', '4.17.22'] }); + expect(forward[1]).toMatchObject({ id: 'GHSA-cccc', aliases: [], severity: 'low' }); + + const noCve = groupAdvisoriesByAlias([ + advisory({ id: 'OSV-2', aliases: ['GHSA-a'], severity: 'high' }), + advisory({ id: 'GHSA-z', aliases: ['OSV-2'], severity: 'low' }), + ]); + expect(noCve).toHaveLength(1); + expect(noCve[0]?.id).toBe('GHSA-a'); + expect(noCve[0]?.aliases).toEqual(['GHSA-z', 'OSV-2']); + expect(noCve[0]?.severity).toBe('low'); + + const twoCves = groupAdvisoriesByAlias([ + advisory({ id: 'OSV-x', aliases: ['GHSA-x'], severity: 'critical', cvss: 9.1 }), + advisory({ id: 'GHSA-x', aliases: ['CVE-2024-2', 'CVE-2020-1'], severity: 'low' }), + ]); + expect(twoCves).toHaveLength(1); + expect(twoCves[0]?.id).toBe('CVE-2020-1'); + expect(twoCves[0]?.aliases).toEqual(['CVE-2024-2', 'GHSA-x', 'OSV-x']); + expect(twoCves[0]?.severity).toBe('low'); + + const alone = groupAdvisoriesByAlias([ + advisory({ id: 'GHSA-zzzz', aliases: ['CVE-2024-2000'] }), + ]); + expect(alone[0]?.id).toBe('GHSA-zzzz'); + expect(alone[0]?.aliases).toEqual(['CVE-2024-2000']); + }); + + it('keeps the same JSON when the linking record is last, and does not merge across packages', async () => { + const first = await scanGrouped(manifest(LODASH_LINK_LAST), TARGETS); + const second = await scanGrouped(manifest(LODASH_LINK_FIRST), [...TARGETS].reverse()); + expect(second).toBe(first); + + const findings = JSON.parse(first) as Finding[]; + expect(findings.map((finding) => `${finding.location}:${finding.details?.advisoryId}`)).toEqual([ + 'left-pad:GHSA-zzzz', + 'lodash:CVE-2024-1111', + 'lodash:GHSA-cccc', + 'ms:GHSA-ms', + ]); + expect(findings[1]?.details?.aliases).toEqual(['GHSA-aaaa', 'GHSA-bbbb', 'OSV-1']); + expect(findings[1]?.message).toBe( + 'lodash@4.17.20: CVE-2024-1111 (critical 9.8) — fix available (4.17.21, 4.17.22)', + ); + expect(findings[1]?.level).toBe('error'); + expect(findings[2]?.details?.aliases).toEqual([]); + expect(findings[2]?.message).toBe('lodash@4.17.20: GHSA-cccc (low)'); + expect(findings[0]?.message).toBe('left-pad@1.0.0: GHSA-zzzz (CVE-2024-2000) (moderate)'); + expect(findings[0]?.details?.aliases).toEqual(['CVE-2024-2000']); + expect(findings[3]?.details?.aliases).toEqual(['OSV-ms']); + expect(findings[3]?.message).toBe('ms@2.1.3: GHSA-ms (moderate) — fix available (3.0.0)'); + + const again = await scanGrouped(manifest(LODASH_LINK_LAST), TARGETS); + expect(again).toBe(first); + + const split = await scanVulnerabilities(TARGETS, { + sem: new Semaphore(2), + offline: true, + manifest: manifest(LODASH_LINK_LAST), + }); + const ungrouped = generateVulnerabilityFindings(split); + expect(ungrouped.filter((finding) => finding.location === 'lodash')).toHaveLength(4); + + const sharedCve: VulnerabilityScanResult = { + source: 'manifest', + totalAdvisories: 4, + severityCounts: { low: 0, moderate: 4, high: 0, critical: 0, unknown: 0 }, + packages: [ + { + ecosystem: 'npm', + package: 'other', + version: '1.0.0', + advisories: [ + advisory({ id: 'GHSA-b', aliases: ['CVE-1'] }), + advisory({ id: 'OSV-b', aliases: ['CVE-1'] }), + ], + }, + { + ecosystem: 'npm', + package: 'lodash', + version: '1.0.0', + advisories: [ + advisory({ id: 'GHSA-a', aliases: ['CVE-1'] }), + advisory({ id: 'OSV-a', aliases: ['GHSA-a'] }), + ], + }, + ], + }; + const perPackage = orderScanFindings(generateVulnerabilityFindings(sharedCve, { groupAliases: true })); + expect(perPackage.map((finding) => `${finding.location}:${finding.details?.advisoryId}`)).toEqual([ + 'lodash:CVE-1', + 'other:CVE-1', + ]); + expect(perPackage[0]?.details?.aliases).toEqual(['GHSA-a', 'OSV-a']); + expect(perPackage[1]?.details?.aliases).toEqual(['GHSA-b', 'OSV-b']); + }); +}); + +describe('vg scan --vulns JSON alias groups', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-alias-groups-')); + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'alias-groups', + version: '1.0.0', + dependencies: { 'left-pad': '1.0.0', lodash: '4.17.20', ms: '2.1.3' }, + }), + ); + fs.writeFileSync( + path.join(root, 'package-lock.json'), + JSON.stringify({ + name: 'alias-groups', + lockfileVersion: 3, + packages: { + '': { + name: 'alias-groups', + version: '1.0.0', + dependencies: { 'left-pad': '1.0.0', lodash: '4.17.20', ms: '2.1.3' }, + }, + 'node_modules/left-pad': { version: '1.0.0' }, + 'node_modules/lodash': { version: '4.17.20' }, + 'node_modules/ms': { version: '2.1.3' }, + }, + }), + ); + fs.writeFileSync( + path.join(root, 'package-versions.json'), + JSON.stringify(manifest(LODASH_LINK_LAST)), + ); + vi.stubGlobal('fetch', vi.fn(async () => { + throw new Error('unexpected network access during offline scan'); + })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + function jsonDocs(logs: readonly string[]): string[] { + return logs.filter((line) => line.startsWith('{')); + } + + it('emits one stable group from two offline --package-manifest scans', async () => { + const logs: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }); + const opts = { + format: 'json' as const, + concurrency: 2, + offline: true, + vulns: true, + quiet: true, + noLocalArtifacts: true, + packageManifest: path.join(root, 'package-versions.json'), + vibgrateVersion: 'test', + }; + try { + const first = await runCoreScan(root, opts); + const second = await runCoreScan(root, opts); + const emitted = jsonDocs(logs); + expect(emitted).toHaveLength(2); + + const vulnJson = (artifact: Awaited>): string => + JSON.stringify(artifact.findings.filter((finding) => finding.ruleId === 'vibgrate/vulnerability')); + expect(vulnJson(second)).toBe(vulnJson(first)); + + const withoutClock = (line: string): string => { + const doc = JSON.parse(line) as { timestamp?: string; durationMs?: number; findings?: Finding[] }; + delete doc.timestamp; + delete doc.durationMs; + return JSON.stringify(doc); + }; + expect(withoutClock(emitted[1]!)).toBe(withoutClock(emitted[0]!)); + + const vulns = first.findings.filter((finding) => finding.ruleId === 'vibgrate/vulnerability'); + expect(vulns.map((finding) => finding.details?.advisoryId)).toEqual([ + 'GHSA-zzzz', + 'CVE-2024-1111', + 'GHSA-cccc', + 'GHSA-ms', + ]); + expect(vulns[1]?.details?.aliases).toEqual(['GHSA-aaaa', 'GHSA-bbbb', 'OSV-1']); + const lodash = first.extended?.vulnerabilities?.packages.find((pkg) => pkg.package === 'lodash'); + expect(lodash?.advisories.map((item) => item.id)).toEqual([ + 'GHSA-aaaa', + 'GHSA-bbbb', + 'OSV-1', + 'GHSA-cccc', + ]); + } finally { + spy.mockRestore(); + } + }); + + it('keeps one SARIF result per advisory id', async () => { + const logs: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }); + try { + await runCoreScan(root, { + format: 'sarif', + concurrency: 2, + offline: true, + vulns: true, + quiet: true, + noLocalArtifacts: true, + packageManifest: path.join(root, 'package-versions.json'), + vibgrateVersion: 'test', + }); + const doc = JSON.parse(jsonDocs(logs)[0] ?? '{}') as { + runs: Array<{ results: Array<{ ruleId: string; properties?: { advisoryId?: string } }> }>; + }; + const ids = doc.runs[0]!.results + .filter((result) => result.ruleId === 'vibgrate/vulnerability') + .map((result) => result.properties?.advisoryId); + expect(ids).toEqual([ + 'GHSA-zzzz', + 'GHSA-aaaa', + 'GHSA-bbbb', + 'OSV-1', + 'GHSA-cccc', + 'GHSA-ms', + 'OSV-ms', + ]); + expect(ids).not.toContain('CVE-2024-1111'); + expect(ids).not.toContain('CVE-2024-2000'); + } finally { + spy.mockRestore(); + } + }); +});