Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions src/core-open/run-core-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
162 changes: 159 additions & 3 deletions src/core-open/scanners/vulnerability-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
const rank = new Map<string, number>();

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<string, VulnerabilityAdvisory[]>();
const idsByRoot = new Map<string, Set<string>>();
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);
Expand Down
Loading
Loading