From 192c5817b83d4684a25d4aaf5b3c2b924806aae0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 12:09:59 +0000 Subject: [PATCH 1/2] fix(scan): stable SARIF partialFingerprints for drift findings Drift results from vg scan --format sarif omitted partialFingerprints unless a baseline suppression was attached, so code scanning could not match unchanged alerts across runs. Every result now sets vg/finding-id/v1 from finding content. Signed-off-by: Cursor Agent Co-authored-by: Crowers --- CHANGELOG.md | 7 + DOCS.md | 16 ++ src/core-open/formatters/sarif.ts | 93 ++++++- test/sarif-partial-fingerprints.test.ts | 340 ++++++++++++++++++++++++ 4 files changed, 448 insertions(+), 8 deletions(-) create mode 100644 test/sarif-partial-fingerprints.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 72af8f05..1359d322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,13 @@ backward compatible. ### Fixed +- **`vg scan --format sarif` sets `partialFingerprints["vg/finding-id/v1"]` on + every result.** Drift findings that are not baseline-suppressed now carry a + content hash (rule, location, and either advisory coordinates or the message + plus stable details). Baseline suppressions and security-pack results keep + the ids they already published under that key. The scan clock, the artifact + timestamp, and the result order are not part of the value. + - **`vg sbom export` merges multi-project lockfiles by ecosystem, name, and version.** The same package from two projects stays one component, and `vibgrate:projects` lists every project that contributed it. A sub-project diff --git a/DOCS.md b/DOCS.md index 5b1d5626..13b68839 100644 --- a/DOCS.md +++ b/DOCS.md @@ -4534,6 +4534,18 @@ Dependency identity in this file is `projects[].type` plus `dependencies[].packa vg scan --vulns --format sarif --out vibgrate.sarif ``` +#### Result identity + +Every result sets `partialFingerprints["vg/finding-id/v1"]`. Code scanning uses that value to match an unchanged alert on the next run. The value comes from the finding. Result order and the scan clock stay out of the hash. `runs[].invocations[].startTimeUtc` remains the artifact timestamp. + +| Result | `vg/finding-id/v1` | +| ------ | ------------------ | +| Drift finding | The first 32 hex characters of SHA-256 over a length-prefixed payload: the tag `vg-sarif-result/v1`, the rule id, and the location. A vulnerability result then adds the ecosystem, package name, installed version, and advisory id, so two advisories on one package stay two alerts. The message is not part of that hash, so a day count in the text does not move it. Any other drift result adds the message and the finding details (object keys sorted). `exposureDays` and `introducedDate` are left out of the hash. | +| Baseline suppression | The baseline suppression id already stored on the result: the hash of the rule id and the location. `suppressions[].properties.id` is that same id. | +| Security-pack result (`vg scan --iac`) | The finding `id`. See [Finding identity](./docs/security-packs.md#finding-identity). | + +The same finding produces the same value. A different rule, location, message, package, or advisory id produces a different one. + #### 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. @@ -4546,6 +4558,7 @@ A near-duplicate code-scanning alert is expected in that case. If the vulnerabil | `level` | `error` for critical and high, `warning` for moderate, `note` for low and unknown. | | `message.text` | Starts with `package@version:` and the advisory id. When an alias starts with `CVE-` and that alias is not already the advisory id, the first such alias is added in parentheses. Further CVE aliases stay in `properties.aliases`. | | `locations[0].physicalLocation.artifactLocation.uri` | The package name. | +| `partialFingerprints["vg/finding-id/v1"]` | Content hash of the ecosystem, package name, installed version, and advisory id, prefixed by the rule id and location. See [Result identity](#result-identity). | | `properties.advisoryId` | The advisory's own id. This is the primary id. | | `properties.aliases` | The alias list, in the order the advisory supplied. Distinct advisory ids stay distinct results. | | `properties` | The finding details, copied as-is: ecosystem, package, installed version, severity, CVSS, and fixing versions, plus introduction details when the scan attributed the advisory. | @@ -4568,6 +4581,9 @@ The ids in this example show the field shape. A real scan fills them from the ad } } ], + "partialFingerprints": { + "vg/finding-id/v1": "dd5a118102af5cbdee3ada40cbaf3021" + }, "properties": { "ecosystem": "npm", "package": "lodash", diff --git a/src/core-open/formatters/sarif.ts b/src/core-open/formatters/sarif.ts index 75708b50..b4cf20dd 100644 --- a/src/core-open/formatters/sarif.ts +++ b/src/core-open/formatters/sarif.ts @@ -1,17 +1,30 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { createHash } from 'node:crypto'; import type { BaselineSuppression, ScanArtifact, Finding, SecurityFinding, SecuritySection, SecuritySeverity } from '../types.js'; +/** + * `partialFingerprints` key on every `vg scan` SARIF result. + * Security-pack results and baseline suppressions keep the id they already + * published under this key. Other drift results use {@link driftResultFingerprint}. + */ +const FINDING_FINGERPRINT_KEY = 'vg/finding-id/v1'; + +/** + * Detail keys that move with the clock. They stay on `properties` and in the + * message, and they never enter the fingerprint. + */ +const CLOCK_DETAIL_KEYS = new Set(['exposureDays', 'introducedDate']); + /** * Generate a SARIF 2.1.0 document from scan artifact. * - * The first run carries the drift findings and is byte-for-byte what it has - * always been. When the artifact carries security-pack findings - * (`extended.security`, from `vg scan --iac`), a second run is appended for - * them — one run per artifact, with every pack listed under - * `tool.extensions`, so a scan that ran no pack produces exactly the same - * bytes as before. + * The first run carries the drift findings. When the artifact carries + * security-pack findings (`extended.security`, from `vg scan --iac`), a + * second run is appended for them — one run per artifact, with every pack + * listed under `tool.extensions`. A scan that ran no pack still emits one run. + * Every result carries `partialFingerprints["vg/finding-id/v1"]`. */ export function formatSarif(artifact: ScanArtifact): object { const suppressed = suppressionIndex(artifact); @@ -105,7 +118,7 @@ function securityRun(security: SecuritySection, artifact: ScanArtifact): object ], // Stable across a move of the block and a rename that keeps the address — // what code-scanning UIs key "same finding as last time" on. - partialFingerprints: { 'vg/finding-id/v1': f.id }, + partialFingerprints: { [FINDING_FINGERPRINT_KEY]: f.id }, properties: { severity: f.severity, address: f.address ?? '', @@ -212,6 +225,12 @@ function toSarifResult(finding: Finding, suppression?: BaselineSuppression) { }, }, ], + // Content identity for code scanning. A baseline match keeps the + // suppression id (rule + location). Every other drift result hashes the + // finding, never the artifact timestamp or the result index. + partialFingerprints: { + [FINDING_FINGERPRINT_KEY]: suppression ? suppression.id : driftResultFingerprint(finding), + }, // Surface structured finding detail (e.g. advisory id, CVSS, fixed version) // to consumers like GitHub code scanning without bloating the message text. ...(finding.details && Object.keys(finding.details).length > 0 ? { properties: finding.details } : {}), @@ -219,7 +238,6 @@ function toSarifResult(finding: Finding, suppression?: BaselineSuppression) { // `baselineComparison.suppressed`, so a baseline match is not dropped. ...(suppression ? { - partialFingerprints: { 'vg/finding-id/v1': suppression.id }, suppressions: [ { kind: 'external', @@ -236,3 +254,62 @@ function toSarifResult(finding: Finding, suppression?: BaselineSuppression) { : {}), }; } + +function encodePart(value: string): string { + return `${Buffer.byteLength(value, 'utf8')}:${value}`; +} + +function stringDetail(details: Finding['details'], key: string): string | undefined { + const value = details?.[key]; + return typeof value === 'string' ? value : undefined; +} + +function canonicalValue(value: unknown): string { + if (value === null) return 'null'; + if (typeof value === 'string') return `s:${value}`; + if (typeof value === 'boolean') return `b:${value ? '1' : '0'}`; + if (typeof value === 'number') return `n:${Number.isFinite(value) ? JSON.stringify(value) : ''}`; + if (Array.isArray(value)) return `a:[${value.map(canonicalValue).join(',')}]`; + if (typeof value === 'object') { + const record = value as Record; + const keys = Object.keys(record).sort(); + return `o:{${keys.map((key) => encodePart(key) + canonicalValue(record[key])).join(',')}}`; + } + return ''; +} + +/** Sorted detail payload. Clock-derived keys are omitted. Object key order is ignored. */ +function canonicalDetails(details: Finding['details']): string { + if (!details) return ''; + const keys = Object.keys(details).filter((key) => !CLOCK_DETAIL_KEYS.has(key)).sort(); + return keys.map((key) => `${encodePart(key)}${encodePart(canonicalValue(details[key]))}`).join('\n'); +} + +/** + * Content fingerprint for a drift result that is not baseline-suppressed. + * 32 lowercase hex characters (128 bits of SHA-256). Parts are length-prefixed + * so concatenation cannot collide. A vulnerability result is identified by + * its package coordinates and advisory id, so a day count in the message + * cannot move the value. Other results include the message, which is what + * separates two frameworks or two packages that share a rule and a path. + */ +function driftResultFingerprint(finding: Finding): string { + const parts = [ + encodePart('vg-sarif-result/v1'), + encodePart(finding.ruleId), + encodePart(finding.location), + ]; + const advisoryId = stringDetail(finding.details, 'advisoryId'); + if (advisoryId) { + parts.push( + encodePart(stringDetail(finding.details, 'ecosystem') ?? ''), + encodePart(stringDetail(finding.details, 'package') ?? ''), + encodePart(stringDetail(finding.details, 'installedVersion') ?? ''), + encodePart(advisoryId), + ); + } else { + parts.push(encodePart(finding.message)); + parts.push(encodePart(canonicalDetails(finding.details))); + } + return createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 32); +} diff --git a/test/sarif-partial-fingerprints.test.ts b/test/sarif-partial-fingerprints.test.ts new file mode 100644 index 00000000..01c1d807 --- /dev/null +++ b/test/sarif-partial-fingerprints.test.ts @@ -0,0 +1,340 @@ +// Owned by the public CLI. Lives here (not under src/core-open, which a vendor +// sync replaces) so a refresh that drops partialFingerprints fails this test. +import { describe, expect, it } from 'vitest'; +import { baselineSuppressionId } from '../src/core-open/baseline-suppressions.js'; +import { formatSarif } from '../src/core-open/formatters/sarif.js'; +import { generateVulnerabilityFindings, scanVulnerabilities } from '../src/core-open/index.js'; +import type { PackageVersionManifest } from '../src/core-open/package-version-manifest.js'; +import type { Finding, ScanArtifact, SecurityFinding, SecuritySection } from '../src/core-open/types.js'; +import { Semaphore } from '../src/core-open/utils/semaphore.js'; + +const FINGERPRINT_KEY = 'vg/finding-id/v1'; + +interface SarifResult { + ruleId: string; + message: { text: string }; + locations: Array<{ physicalLocation: { artifactLocation: { uri: string } } }>; + partialFingerprints?: Record; + properties?: Record; + suppressions?: Array<{ properties?: { id?: string } }>; +} + +interface SarifDoc { + runs: Array<{ + results: SarifResult[]; + invocations?: Array<{ startTimeUtc?: string }>; + }>; +} + +/** Manifest order is deliberately not the documented SARIF order. */ +const MANIFEST: PackageVersionManifest = { + npm: { + lodash: { + vulns: [ + { + id: 'CVE-2024-1111', + aliases: ['GHSA-bbbb'], + severity: 'high', + cvss: 7.5, + ranges: [{ introduced: '0', fixed: '4.17.21' }], + }, + { + id: 'GHSA-bbbb', + aliases: ['CVE-2024-1111', 'OSV-1'], + severity: 'critical', + cvss: 9.1, + ranges: [{ introduced: '0', fixed: '4.17.21' }], + }, + ], + }, + }, +}; + +const DRIFT: Finding[] = [ + { + ruleId: 'vibgrate/runtime-lag', + level: 'warning', + message: 'Node.js runtime ">=20.0.0" is 2 major versions behind (latest: 22.0.0).', + location: 'app', + }, + { + ruleId: 'vibgrate/dependency-major-lag', + level: 'error', + message: 'chalk is 3 major versions behind (spec: ^4.0.0, latest: 5.4.0).', + location: 'app', + }, + { + ruleId: 'vibgrate/dependency-major-lag', + level: 'error', + message: 'left-pad is 4 major versions behind (spec: ^0.0.1, latest: 1.3.0).', + location: 'app', + }, + { + ruleId: 'vibgrate/license-parse-failed', + level: 'warning', + message: 'License "Also-Not-SPDX" on left-pad could not be resolved as SPDX.', + location: 'package.json', + details: { sourcePath: 'package.json', raw: 'Also-Not-SPDX' }, + }, +]; + +const SECURITY: SecurityFinding[] = [ + { + id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + pack: 'iac-cis-v1', + packVersion: '1', + rule: 'aws-s3-public', + severity: 'high', + message: 'S3 bucket logs allows public access.', + path: 'infra/s3.tf', + line: 1, + address: 'aws_s3_bucket.logs', + }, + { + id: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + pack: 'iac-cis-v1', + packVersion: '1', + rule: 'aws-s3-public', + severity: 'high', + message: 'S3 bucket uploads allows public access.', + path: 'infra/s3.tf', + line: 8, + address: 'aws_s3_bucket.uploads', + }, +]; + +function securitySection(findings: SecurityFinding[]): SecuritySection { + return { + schema: 'vg.security.v1', + engine: 'test-engine', + packs: { 'iac-cis-v1': '1' }, + facts: { received: findings.length, evaluated: findings.length, rejected: 0 }, + findings, + }; +} + +function artifact(options: { + timestamp: string; + findings: Finding[]; + security?: SecurityFinding[]; + baselineId?: { ruleId: string; location: string; id: string }; +}): ScanArtifact { + return { + schemaVersion: '1.0', + timestamp: options.timestamp, + vibgrateVersion: '0.0.0-test', + rootPath: '.', + projects: [], + drift: { + score: 0, + riskLevel: 'low', + components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, + }, + findings: options.findings, + ...(options.baselineId + ? { + baselineComparison: { + compared: true, + suppressedCount: 1, + suppressed: [options.baselineId], + }, + } + : {}), + ...(options.security + ? { extended: { security: securitySection(options.security) } } + : {}), + }; +} + +function fingerprint(result: SarifResult): string { + const value = result.partialFingerprints?.[FINGERPRINT_KEY]; + if (!value) throw new Error(`missing ${FINGERPRINT_KEY} on ${result.ruleId}`); + return value; +} + +/** Identity that ignores result order, message wording, and detail key order. */ +function advisoryIdentity(result: SarifResult): string | undefined { + const advisoryId = result.properties?.advisoryId; + if (typeof advisoryId !== 'string' || advisoryId.length === 0) return undefined; + return [ + 'advisory', + result.ruleId, + result.locations[0]?.physicalLocation.artifactLocation.uri ?? '', + String(result.properties?.ecosystem ?? ''), + String(result.properties?.package ?? ''), + String(result.properties?.installedVersion ?? ''), + advisoryId, + ].join('\n'); +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + out[key] = sortKeys((value as Record)[key]); + } + return out; + } + return value; +} + +function driftIdentity(result: SarifResult): string { + return [ + 'drift', + result.ruleId, + result.locations[0]?.physicalLocation.artifactLocation.uri ?? '', + result.message.text, + JSON.stringify(sortKeys(result.properties ?? null)), + ].join('\n'); +} + +function indexFingerprints(doc: SarifDoc): Map { + const index = new Map(); + for (const result of doc.runs[0]?.results ?? []) { + const key = advisoryIdentity(result) ?? driftIdentity(result); + index.set(key, fingerprint(result)); + } + return index; +} + +function reverseKeys(details: Record): Record { + const out: Record = {}; + for (const key of Object.keys(details).reverse()) out[key] = details[key]; + return out; +} + +function withUnstableSurface(finding: Finding): Finding { + if (typeof finding.details?.advisoryId !== 'string') { + if (!finding.details) return finding; + return { ...finding, details: reverseKeys(finding.details) }; + } + const aliases = finding.details.aliases; + return { + ...finding, + message: `${finding.message} — introduced by Ada in abcdef (9d exposed)`, + details: reverseKeys({ + ...finding.details, + aliases: Array.isArray(aliases) ? [...aliases].reverse() : aliases, + exposureDays: 9, + introducedDate: '2020-01-02', + }), + }; +} + +describe('vg scan SARIF partialFingerprints', () => { + it('matches fingerprints when only the timestamp and finding order change', async () => { + const scanned = await scanVulnerabilities( + [{ ecosystem: 'npm', package: 'lodash', version: '4.17.20' }], + { sem: new Semaphore(1), offline: true, manifest: MANIFEST }, + ); + const vulns = generateVulnerabilityFindings(scanned); + const findings = [...DRIFT, ...vulns]; + const first = artifact({ + timestamp: '2026-01-01T00:00:00.000Z', + findings, + security: SECURITY, + }); + const second = artifact({ + timestamp: '2026-06-15T12:34:56.000Z', + findings: [...findings].reverse().map(withUnstableSurface), + security: [...SECURITY].reverse(), + }); + + const sarifA = formatSarif(first) as SarifDoc; + const sarifB = formatSarif(second) as SarifDoc; + expect(formatSarif(first)).toEqual(sarifA); + + expect(sarifA.runs[0]?.invocations?.[0]?.startTimeUtc).toBe('2026-01-01T00:00:00.000Z'); + expect(sarifB.runs[0]?.invocations?.[0]?.startTimeUtc).toBe('2026-06-15T12:34:56.000Z'); + expect(sarifA.runs[0]?.results.map((result) => result.ruleId)).not.toEqual( + sarifB.runs[0]?.results.map((result) => result.ruleId), + ); + + const indexA = indexFingerprints(sarifA); + const indexB = indexFingerprints(sarifB); + expect(indexB).toEqual(indexA); + + for (const doc of [sarifA, sarifB]) { + const results = doc.runs.flatMap((run) => run.results); + expect(results.length).toBeGreaterThan(0); + for (const result of results) { + expect(fingerprint(result)).toMatch(/^[0-9a-f]{32}$/); + } + } + + // The day count stays in the message. It does not move the fingerprint. + const exposed = sarifB.runs[0]?.results.find((result) => result.properties?.advisoryId === 'GHSA-bbbb'); + const original = sarifA.runs[0]?.results.find((result) => result.properties?.advisoryId === 'GHSA-bbbb'); + expect(exposed?.message.text).toContain('(9d exposed)'); + expect(original?.message.text).not.toContain('exposed'); + expect(fingerprint(exposed!)).toBe(fingerprint(original!)); + + const securityA = sarifA.runs[1]?.results.map(fingerprint).sort(); + const securityB = sarifB.runs[1]?.results.map(fingerprint).sort(); + expect(securityA).toEqual(['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb']); + expect(securityB).toEqual(securityA); + }); + + it('gives distinct fingerprints to distinct findings', async () => { + const scanned = await scanVulnerabilities( + [{ ecosystem: 'npm', package: 'lodash', version: '4.17.20' }], + { sem: new Semaphore(1), offline: true, manifest: MANIFEST }, + ); + const findings = [...DRIFT, ...generateVulnerabilityFindings(scanned)]; + const sarif = formatSarif(artifact({ + timestamp: '2026-01-01T00:00:00.000Z', + findings, + security: SECURITY, + })) as SarifDoc; + + const drift = sarif.runs[0]?.results ?? []; + const byMessage = new Map(drift.map((result) => [result.message.text, fingerprint(result)])); + const chalk = byMessage.get(DRIFT[1]!.message); + const leftPad = byMessage.get(DRIFT[2]!.message); + expect(chalk).toBeDefined(); + expect(leftPad).toBeDefined(); + expect(chalk).not.toBe(leftPad); + expect(byMessage.get(DRIFT[0]!.message)).not.toBe(chalk); + + const ghsa = drift.find((result) => result.properties?.advisoryId === 'GHSA-bbbb'); + const cve = drift.find((result) => result.properties?.advisoryId === 'CVE-2024-1111'); + expect(ghsa?.message.text).toBe( + 'lodash@4.17.20: GHSA-bbbb (CVE-2024-1111) (critical 9.1) — fix available (4.17.21)', + ); + // Documented in DOCS.md under SARIF result identity. + expect(fingerprint(ghsa!)).toBe('dd5a118102af5cbdee3ada40cbaf3021'); + expect(fingerprint(ghsa!)).not.toBe(fingerprint(cve!)); + + const all = sarif.runs.flatMap((run) => run.results.map(fingerprint)); + expect(new Set(all).size).toBe(all.length); + }); + + it('keeps the baseline suppression id on vg/finding-id/v1', () => { + const finding = DRIFT[0]!; + const id = baselineSuppressionId(finding.ruleId, finding.location); + const suppressed = formatSarif(artifact({ + timestamp: '2026-01-01T00:00:00.000Z', + findings: [finding, DRIFT[1]!], + baselineId: { ruleId: finding.ruleId, location: finding.location, id }, + })) as SarifDoc; + const again = formatSarif(artifact({ + timestamp: '2026-08-01T00:00:00.000Z', + findings: [DRIFT[1]!, finding], + baselineId: { ruleId: finding.ruleId, location: finding.location, id }, + })) as SarifDoc; + + const match = (doc: SarifDoc) => doc.runs[0]?.results.find((result) => result.ruleId === finding.ruleId); + expect(match(suppressed)?.message.text).toBe(finding.message); + expect(fingerprint(match(suppressed)!)).toBe(id); + expect(match(suppressed)?.suppressions?.[0]?.properties?.id).toBe(id); + expect(fingerprint(match(again)!)).toBe(id); + + const open = formatSarif(artifact({ + timestamp: '2026-01-01T00:00:00.000Z', + findings: [finding], + })) as SarifDoc; + expect(fingerprint(open.runs[0]!.results[0]!)).not.toBe(id); + expect(open.runs[0]!.results[0]?.suppressions).toBeUndefined(); + }); +}); From 777d73bc379ff8de3894dd8ab95d53018b834c4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 20:39:39 +0000 Subject: [PATCH 2/2] refactor(sarif): align partialFingerprints with canonical finding id Drift fingerprints reuse the baseline-suppression content id when a finding is identified by its rule and location. Vulnerability results add ecosystem, package, advisory id, and a package URL when the finding has one. Major-lag results use the package or framework name, so a newer count does not change the id. Message text is unchanged. Signed-off-by: Cursor Agent Co-authored-by: Crowers --- CHANGELOG.md | 12 +- DOCS.md | 28 +- README.md | 2 +- src/core-open/formatters/sarif.ts | 146 +++++----- test/sarif-fingerprints.test.ts | 230 ++++++++++++++++ test/sarif-partial-fingerprints.test.ts | 340 ------------------------ 6 files changed, 326 insertions(+), 432 deletions(-) create mode 100644 test/sarif-fingerprints.test.ts delete mode 100644 test/sarif-partial-fingerprints.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1359d322..54650644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,12 +24,12 @@ backward compatible. ### Fixed -- **`vg scan --format sarif` sets `partialFingerprints["vg/finding-id/v1"]` on - every result.** Drift findings that are not baseline-suppressed now carry a - content hash (rule, location, and either advisory coordinates or the message - plus stable details). Baseline suppressions and security-pack results keep - the ids they already published under that key. The scan clock, the artifact - timestamp, and the result order are not part of the value. +- **`vg scan --format sarif` sets a content-stable fingerprint on every result.** + `partialFingerprints["vg/finding-id/v1"]` is derived from the finding (rule, + location, and advisory or package when those identify it), not from the scan + time or the result order. GitHub code scanning can keep the same alert across + runs. Message text is unchanged. Baseline suppressions and infrastructure + findings keep the ids they already used. - **`vg sbom export` merges multi-project lockfiles by ecosystem, name, and version.** The same package from two projects stays one component, and diff --git a/DOCS.md b/DOCS.md index cfdba4b3..6a0610cc 100644 --- a/DOCS.md +++ b/DOCS.md @@ -100,6 +100,7 @@ For a quick overview, see the [README](./README.md). This document covers everyt - [Text](#text) - [JSON Artifact](#json-artifact) - [SARIF](#sarif) + - [Result fingerprints](#result-fingerprints) - [Advisories with several ids](#advisories-with-several-ids) - [Markdown](#markdown) - [JUnit](#junit) @@ -4616,21 +4617,26 @@ Dependency identity in this file is `projects[].type` plus `dependencies[].packa [Static Analysis Results Interchange Format](https://sarifweb.azurewebsites.net/) (SARIF) 2.1.0. GitHub code scanning and Azure DevOps read this file. It contains findings only. The DriftScore and the other metrics stay in the JSON artifact. Drift findings come first. Vulnerability findings follow when the scan ran with `--vulns`. +Every result includes `partialFingerprints`. Code scanning uses that object to recognize the same finding on a later run, so an unchanged finding stays one alert. The scan time, the result order, and the artifact timestamp are not part of the value. The message text is left as the scanner wrote it. + ```bash vg scan --vulns --format sarif --out vibgrate.sarif ``` -#### Result identity +#### Result fingerprints -Every result sets `partialFingerprints["vg/finding-id/v1"]`. Code scanning uses that value to match an unchanged alert on the next run. The value comes from the finding. Result order and the scan clock stay out of the hash. `runs[].invocations[].startTimeUtc` remains the artifact timestamp. +The key on every result is `vg/finding-id/v1`. The value is a content id: 32 lowercase hex characters. What goes into it depends on the result. -| Result | `vg/finding-id/v1` | -| ------ | ------------------ | -| Drift finding | The first 32 hex characters of SHA-256 over a length-prefixed payload: the tag `vg-sarif-result/v1`, the rule id, and the location. A vulnerability result then adds the ecosystem, package name, installed version, and advisory id, so two advisories on one package stay two alerts. The message is not part of that hash, so a day count in the text does not move it. Any other drift result adds the message and the finding details (object keys sorted). `exposureDays` and `introducedDate` are left out of the hash. | -| Baseline suppression | The baseline suppression id already stored on the result: the hash of the rule id and the location. `suppressions[].properties.id` is that same id. | -| Security-pack result (`vg scan --iac`) | The finding `id`. See [Finding identity](./docs/security-packs.md#finding-identity). | +| Result | What the value is made from | +| ------ | --------------------------- | +| Drift finding identified by its rule and location (runtime end-of-life, runtime lag, dependency rot) | The rule id and the location. This is the same id `vg scan --baseline` stores when that finding is suppressed. A change to the message, such as a newer latest version, does not change the value. | +| Known vulnerability (`vibgrate/vulnerability`) | The rule id, the location, the ecosystem, the package name, and the advisory id. A package URL is included when the finding has one. Two advisories on one package get two values. The installed version, CVSS score, aliases, and message are not included. | +| Dependency or framework major lag | The rule id, the location, and the package or framework name. The name is the text before ` is major versions behind`. A later count, or a newer latest version, does not change the value. If the message does not have that shape, the whole message is used. | +| License that could not be resolved | The rule id, the location, the declared license text, and the message. The message names the package, so two packages in one manifest stay distinct. | +| Finding suppressed by `--baseline` | The baseline id for that rule and location. The key is unchanged. That id does not include an advisory id, so every suppressed result with that rule and location shares it. | +| Infrastructure finding (`vg scan --iac`) | The finding id. Moving the block without changing the address does not change the value. | -The same finding produces the same value. A different rule, location, message, package, or advisory id produces a different one. +The same finding contents produce the same value. A different finding produces a different value. #### Advisories with several ids @@ -4644,14 +4650,14 @@ A near-duplicate code-scanning alert is expected in that case. If the vulnerabil | `level` | `error` for critical and high, `warning` for moderate, `note` for low and unknown. | | `message.text` | Starts with `package@version:` and the advisory id. When an alias starts with `CVE-` and that alias is not already the advisory id, the first such alias is added in parentheses. Further CVE aliases stay in `properties.aliases`. | | `locations[0].physicalLocation.artifactLocation.uri` | The package name. | -| `partialFingerprints["vg/finding-id/v1"]` | Content hash of the ecosystem, package name, installed version, and advisory id, prefixed by the rule id and location. See [Result identity](#result-identity). | +| `partialFingerprints["vg/finding-id/v1"]` | Content id for this result. See [Result fingerprints](#result-fingerprints). | | `properties.advisoryId` | The advisory's own id. This is the primary id. | | `properties.aliases` | The alias list, in the order the advisory supplied. Distinct advisory ids stay distinct results. | | `properties` | The finding details, copied as-is: ecosystem, package, installed version, severity, CVSS, and fixing versions, plus introduction details when the scan attributed the advisory. | The same advisory set always produces the same result order. Packages are ordered by ecosystem, package name, then version. Advisories on one package are ordered by severity from critical down to unknown, then by advisory id. SARIF emits results in that order: drift findings, then those vulnerability results. -The ids in this example show the field shape. A real scan fills them from the advisory it read. A critical `GHSA-bbbb` whose aliases are `CVE-2024-1111` and `OSV-1`, fixed in 4.17.21, is one result: +The ids in this example show the field shape. A real scan fills them from the advisory it read. The fingerprint is the content id for the rule, the location, the ecosystem, the package, and `GHSA-bbbb` shown here. A critical `GHSA-bbbb` whose aliases are `CVE-2024-1111` and `OSV-1`, fixed in 4.17.21, is one result: ```json { @@ -4668,7 +4674,7 @@ The ids in this example show the field shape. A real scan fills them from the ad } ], "partialFingerprints": { - "vg/finding-id/v1": "dd5a118102af5cbdee3ada40cbaf3021" + "vg/finding-id/v1": "82efba63199c173114225d3195aa15aa" }, "properties": { "ecosystem": "npm", diff --git a/README.md b/README.md index df01df52..22b3f096 100644 --- a/README.md +++ b/README.md @@ -427,7 +427,7 @@ One scan gives you: `vg scan --vulns` checks your installed dependencies against the public [OSV](https://vibgrate.com/glossary/osv) database and reports each known vulnerability with its severity, CVSS score, and the version that fixes it — as text, JSON, or SARIF. JSON also includes `epss`, `epssPercentile`, and `kev` when the advisory data already carried them; a missing score is omitted, never written as `0`. Add `--package-manifest` to run it fully offline from a local advisory bundle. Offline and manifest scans do not contact an EPSS service. A missing or invalid manifest exits `1` and does not scan. The manifest shape and the error text are in [DOCS.md](./DOCS.md#offline-scan-with-a-package-version-manifest). -SARIF writes one result per package and advisory (`vg scan --vulns --format sarif`). 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 and result order: [DOCS.md](./DOCS.md#advisories-with-several-ids). +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). ```bash vg scan --vulns # drift score + known vulnerabilities diff --git a/src/core-open/formatters/sarif.ts b/src/core-open/formatters/sarif.ts index b4cf20dd..e1723791 100644 --- a/src/core-open/formatters/sarif.ts +++ b/src/core-open/formatters/sarif.ts @@ -2,29 +2,25 @@ // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. import { createHash } from 'node:crypto'; +import { baselineSuppressionId } from '../baseline-suppressions.js'; import type { BaselineSuppression, ScanArtifact, Finding, SecurityFinding, SecuritySection, SecuritySeverity } from '../types.js'; /** - * `partialFingerprints` key on every `vg scan` SARIF result. - * Security-pack results and baseline suppressions keep the id they already - * published under this key. Other drift results use {@link driftResultFingerprint}. + * SARIF `partialFingerprints` key for every result `vg scan` emits. + * The value is a content id. It is never a result index, a clock, or the + * artifact timestamp. */ const FINDING_FINGERPRINT_KEY = 'vg/finding-id/v1'; -/** - * Detail keys that move with the clock. They stay on `properties` and in the - * message, and they never enter the fingerprint. - */ -const CLOCK_DETAIL_KEYS = new Set(['exposureDays', 'introducedDate']); - /** * Generate a SARIF 2.1.0 document from scan artifact. * - * The first run carries the drift findings. When the artifact carries + * The first run is the drift findings. Every result in it carries + * `partialFingerprints["vg/finding-id/v1"]`. When the artifact carries * security-pack findings (`extended.security`, from `vg scan --iac`), a * second run is appended for them — one run per artifact, with every pack - * listed under `tool.extensions`. A scan that ran no pack still emits one run. - * Every result carries `partialFingerprints["vg/finding-id/v1"]`. + * listed under `tool.extensions`. A scan that ran no pack is still one run, + * and an empty pack section does not change the document. */ export function formatSarif(artifact: ScanArtifact): object { const suppressed = suppressionIndex(artifact); @@ -211,6 +207,67 @@ function suppressionIndex(artifact: ScanArtifact): Map is major versions behind`. + * The name is stable when the count or the latest version changes. + */ +const LAG_SUBJECT_RULES = new Set([ + 'vibgrate/dependency-major-lag', + 'vibgrate/framework-major-lag', +]); + +/** License failures name the package only in the message. */ +const LICENSE_PARSE_FAILED_RULE = 'vibgrate/license-parse-failed'; + +const LAG_SUBJECT = /^(.*) is \d+ major versions behind\b/; + +function identityDetail(details: Finding['details'], key: string): string | null { + const value = details?.[key]; + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function lagSubject(message: string): string | null { + const subject = LAG_SUBJECT.exec(message)?.[1]?.trim() ?? ''; + return subject.length > 0 ? subject : null; +} + +function hashFingerprint(parts: readonly string[]): string { + const input = parts.map((part) => `${part.length}\n${part}`).join('\n'); + return createHash('sha256').update(input).digest('hex').slice(0, 32); +} + +/** + * Content id for a drift or vulnerability result that is not baseline-suppressed. + * + * Rule and location are always included. Advisory id, ecosystem, package name, + * package URL, and declared license text are included when the finding carries + * them. Major-lag findings add the package or framework name from the message. + * License findings add the message, which names the package. Anything else + * (the scan clock, result order, severity, CVSS, aliases, installed version) + * is left out, so a refreshed wording of the same finding keeps the id. + * + * A finding with no extra identity uses {@link baselineSuppressionId}, so the + * fingerprint matches the id `--baseline` will record for that rule and location. + */ +function driftResultFingerprint(finding: Finding): string { + const extras: string[] = []; + for (const key of IDENTITY_DETAIL_KEYS) { + const value = identityDetail(finding.details, key); + if (value) extras.push(key, value); + } + if (LAG_SUBJECT_RULES.has(finding.ruleId)) { + extras.push('subject', lagSubject(finding.message) ?? finding.message); + } else if (finding.ruleId === LICENSE_PARSE_FAILED_RULE) { + extras.push('message', finding.message); + } + if (extras.length === 0) return baselineSuppressionId(finding.ruleId, finding.location); + return hashFingerprint(['vg-sarif-partial/v1', finding.ruleId, finding.location, ...extras]); +} + function toSarifResult(finding: Finding, suppression?: BaselineSuppression) { return { ruleId: finding.ruleId, @@ -225,9 +282,9 @@ function toSarifResult(finding: Finding, suppression?: BaselineSuppression) { }, }, ], - // Content identity for code scanning. A baseline match keeps the - // suppression id (rule + location). Every other drift result hashes the - // finding, never the artifact timestamp or the result index. + // Content-stable across runs. A baseline match keeps the suppression id + // (`vg/finding-id/v1` already meant that). Every other result gets an id + // from the finding itself, not from order or the artifact clock. partialFingerprints: { [FINDING_FINGERPRINT_KEY]: suppression ? suppression.id : driftResultFingerprint(finding), }, @@ -254,62 +311,3 @@ function toSarifResult(finding: Finding, suppression?: BaselineSuppression) { : {}), }; } - -function encodePart(value: string): string { - return `${Buffer.byteLength(value, 'utf8')}:${value}`; -} - -function stringDetail(details: Finding['details'], key: string): string | undefined { - const value = details?.[key]; - return typeof value === 'string' ? value : undefined; -} - -function canonicalValue(value: unknown): string { - if (value === null) return 'null'; - if (typeof value === 'string') return `s:${value}`; - if (typeof value === 'boolean') return `b:${value ? '1' : '0'}`; - if (typeof value === 'number') return `n:${Number.isFinite(value) ? JSON.stringify(value) : ''}`; - if (Array.isArray(value)) return `a:[${value.map(canonicalValue).join(',')}]`; - if (typeof value === 'object') { - const record = value as Record; - const keys = Object.keys(record).sort(); - return `o:{${keys.map((key) => encodePart(key) + canonicalValue(record[key])).join(',')}}`; - } - return ''; -} - -/** Sorted detail payload. Clock-derived keys are omitted. Object key order is ignored. */ -function canonicalDetails(details: Finding['details']): string { - if (!details) return ''; - const keys = Object.keys(details).filter((key) => !CLOCK_DETAIL_KEYS.has(key)).sort(); - return keys.map((key) => `${encodePart(key)}${encodePart(canonicalValue(details[key]))}`).join('\n'); -} - -/** - * Content fingerprint for a drift result that is not baseline-suppressed. - * 32 lowercase hex characters (128 bits of SHA-256). Parts are length-prefixed - * so concatenation cannot collide. A vulnerability result is identified by - * its package coordinates and advisory id, so a day count in the message - * cannot move the value. Other results include the message, which is what - * separates two frameworks or two packages that share a rule and a path. - */ -function driftResultFingerprint(finding: Finding): string { - const parts = [ - encodePart('vg-sarif-result/v1'), - encodePart(finding.ruleId), - encodePart(finding.location), - ]; - const advisoryId = stringDetail(finding.details, 'advisoryId'); - if (advisoryId) { - parts.push( - encodePart(stringDetail(finding.details, 'ecosystem') ?? ''), - encodePart(stringDetail(finding.details, 'package') ?? ''), - encodePart(stringDetail(finding.details, 'installedVersion') ?? ''), - encodePart(advisoryId), - ); - } else { - parts.push(encodePart(finding.message)); - parts.push(encodePart(canonicalDetails(finding.details))); - } - return createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 32); -} diff --git a/test/sarif-fingerprints.test.ts b/test/sarif-fingerprints.test.ts new file mode 100644 index 00000000..841c020e --- /dev/null +++ b/test/sarif-fingerprints.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; +import { baselineSuppressionId } from '../src/core-open/baseline-suppressions.js'; +import { formatSarif } from '../src/core-open/formatters/sarif.js'; +import { generateVulnerabilityFindings, scanVulnerabilities } from '../src/core-open/index.js'; +import type { PackageVersionManifest } from '../src/core-open/package-version-manifest.js'; +import type { Finding, ScanArtifact } from '../src/core-open/types.js'; +import { Semaphore } from '../src/core-open/utils/semaphore.js'; + +interface SarifResult { + ruleId: string; + message: { text: string }; + locations: Array<{ physicalLocation: { artifactLocation: { uri: string } } }>; + partialFingerprints?: Record; + properties?: Record; +} + +interface SarifDoc { + runs: Array<{ + invocations?: Array<{ startTimeUtc?: string }>; + results: SarifResult[]; + }>; +} + +const MANIFEST: PackageVersionManifest = { + npm: { + lodash: { + vulns: [ + { + id: 'GHSA-bbbb', + aliases: ['CVE-2024-1111'], + severity: 'critical', + cvss: 9.1, + ranges: [{ introduced: '0', fixed: '4.17.21' }], + }, + { + id: 'CVE-2024-1111', + aliases: ['GHSA-bbbb'], + severity: 'high', + cvss: 7.5, + ranges: [{ introduced: '0', fixed: '4.17.21' }], + }, + ], + }, + chalk: { + vulns: [ + { + id: 'GHSA-chalk', + aliases: [], + severity: 'moderate', + ranges: [{ introduced: '0' }], + }, + ], + }, + }, +}; + +const TARGETS = [ + { ecosystem: 'npm' as const, package: 'lodash', version: '4.17.20' }, + { ecosystem: 'npm' as const, package: 'chalk', version: '4.1.2' }, +]; + +const DRIFT: Finding[] = [ + { + ruleId: 'vibgrate/runtime-lag', + level: 'warning', + message: 'Node.js runtime ">=20.0.0" is 2 major versions behind.', + location: 'app', + }, + { + ruleId: 'vibgrate/dependency-rot', + level: 'error', + message: '20% of dependencies are 2+ major versions behind.', + location: 'app', + }, + { + ruleId: 'vibgrate/dependency-major-lag', + level: 'error', + message: 'left-pad is 3 major versions behind (spec: ^1.0.0, latest: 5.0.0).', + location: 'app', + }, + { + ruleId: 'vibgrate/dependency-major-lag', + level: 'error', + message: 'moment is 4 major versions behind (spec: ^2.0.0, latest: 2.30.1).', + location: 'app', + }, +]; + +function artifact(findings: Finding[], timestamp: string): ScanArtifact { + return { + schemaVersion: '1.0', + timestamp, + vibgrateVersion: '0.0.0-test', + rootPath: '.', + projects: [], + drift: { + score: 40, + riskLevel: 'moderate', + components: { runtimeScore: 40, frameworkScore: 40, dependencyScore: 40, eolScore: 40 }, + }, + findings, + }; +} + +function resultKey(result: SarifResult): string { + return [ + result.ruleId, + result.locations[0]?.physicalLocation.artifactLocation.uri ?? '', + result.message.text, + String(result.properties?.advisoryId ?? ''), + ].join('\n'); +} + +function fingerprintsByKey(doc: SarifDoc): Map { + const map = new Map(); + for (const result of doc.runs[0]?.results ?? []) { + const value = result.partialFingerprints?.['vg/finding-id/v1']; + expect(value).toMatch(/^[0-9a-f]{32}$/); + map.set(resultKey(result), value!); + } + return map; +} + +describe('vg scan SARIF partial fingerprints', () => { + it('matches fingerprints for the same offline fixture when timestamp and order differ', async () => { + const sem = new Semaphore(1); + const scanned = await scanVulnerabilities(TARGETS, { sem, offline: true, manifest: MANIFEST }); + const again = await scanVulnerabilities([...TARGETS].reverse(), { sem, offline: true, manifest: MANIFEST }); + expect(again.packages).toEqual(scanned.packages); + + const vulns = generateVulnerabilityFindings(scanned); + expect(vulns.length).toBeGreaterThan(1); + const findings = [...DRIFT, ...vulns]; + + const first = formatSarif(artifact(findings, '2026-01-01T00:00:00.000Z')) as SarifDoc; + const second = formatSarif(artifact([...findings].reverse(), '2026-10-07T15:04:00.000Z')) as SarifDoc; + + expect(first.runs[0]?.invocations?.[0]?.startTimeUtc).toBe('2026-01-01T00:00:00.000Z'); + expect(second.runs[0]?.invocations?.[0]?.startTimeUtc).toBe('2026-10-07T15:04:00.000Z'); + + const a = fingerprintsByKey(first); + const b = fingerprintsByKey(second); + expect(b).toEqual(a); + expect(new Set(a.values()).size).toBe(findings.length); + + for (const finding of findings) { + const result = first.runs[0]?.results.find((r) => r.message.text === finding.message); + expect(result?.message.text).toBe(finding.message); + } + + const runtime = first.runs[0]?.results.find((r) => r.ruleId === 'vibgrate/runtime-lag'); + expect(runtime?.partialFingerprints).toEqual({ + 'vg/finding-id/v1': baselineSuppressionId('vibgrate/runtime-lag', 'app'), + }); + }); + + it('gives distinct findings distinct fingerprints', async () => { + const sem = new Semaphore(1); + const scanned = await scanVulnerabilities(TARGETS, { sem, offline: true, manifest: MANIFEST }); + const findings = [...DRIFT, ...generateVulnerabilityFindings(scanned)]; + const doc = formatSarif(artifact(findings, '2026-10-07T00:00:00.000Z')) as SarifDoc; + const values = (doc.runs[0]?.results ?? []).map((r) => r.partialFingerprints?.['vg/finding-id/v1']); + expect(values.every((value) => typeof value === 'string' && value.length > 0)).toBe(true); + expect(new Set(values).size).toBe(findings.length); + + const lodash = (doc.runs[0]?.results ?? []).filter( + (r) => r.properties?.package === 'lodash', + ); + expect(lodash).toHaveLength(2); + expect(lodash[0]?.partialFingerprints?.['vg/finding-id/v1']).not.toBe( + lodash[1]?.partialFingerprints?.['vg/finding-id/v1'], + ); + + const lag = (doc.runs[0]?.results ?? []).filter((r) => r.ruleId === 'vibgrate/dependency-major-lag'); + expect(lag).toHaveLength(2); + expect(lag[0]?.partialFingerprints?.['vg/finding-id/v1']).not.toBe( + lag[1]?.partialFingerprints?.['vg/finding-id/v1'], + ); + }); + + it('keeps a major-lag fingerprint when the latest version in the message changes', () => { + const location = 'services/web'; + const before: Finding = { + ruleId: 'vibgrate/dependency-major-lag', + level: 'error', + message: 'chalk is 3 major versions behind (spec: ^4.0.0, latest: 5.4.0).', + location, + }; + const after: Finding = { + ...before, + message: 'chalk is 4 major versions behind (spec: ^4.0.0, latest: 8.0.0).', + }; + const other: Finding = { + ...before, + message: 'lodash is 3 major versions behind (spec: ^4.17.0, latest: 5.4.0).', + }; + const fp = (finding: Finding) => { + const doc = formatSarif(artifact([finding], '2026-02-02T00:00:00.000Z')) as SarifDoc; + return doc.runs[0]?.results[0]?.partialFingerprints?.['vg/finding-id/v1']; + }; + expect(fp(before)).toBe(fp(after)); + expect(fp(before)).not.toBe(fp(other)); + const doc = formatSarif(artifact([after], '2026-03-03T00:00:00.000Z')) as SarifDoc; + expect(doc.runs[0]?.results[0]?.message.text).toBe(after.message); + }); + + it('matches the vulnerability fingerprint shown in DOCS.md', () => { + const finding: Finding = { + ruleId: 'vibgrate/vulnerability', + level: 'error', + message: 'lodash@4.17.20: GHSA-bbbb (CVE-2024-1111) (critical 9.1) — fix available (4.17.21)', + location: 'lodash', + details: { + ecosystem: 'npm', + package: 'lodash', + installedVersion: '4.17.20', + advisoryId: 'GHSA-bbbb', + aliases: ['CVE-2024-1111', 'OSV-1'], + severity: 'critical', + cvss: 9.1, + fixedVersions: ['4.17.21'], + }, + }; + const doc = formatSarif(artifact([finding], '2026-01-01T00:00:00.000Z')) as SarifDoc; + expect(doc.runs[0]?.results[0]?.partialFingerprints).toEqual({ + 'vg/finding-id/v1': '82efba63199c173114225d3195aa15aa', + }); + expect(doc.runs[0]?.results[0]?.message.text).toBe(finding.message); + }); +}); diff --git a/test/sarif-partial-fingerprints.test.ts b/test/sarif-partial-fingerprints.test.ts deleted file mode 100644 index 01c1d807..00000000 --- a/test/sarif-partial-fingerprints.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -// Owned by the public CLI. Lives here (not under src/core-open, which a vendor -// sync replaces) so a refresh that drops partialFingerprints fails this test. -import { describe, expect, it } from 'vitest'; -import { baselineSuppressionId } from '../src/core-open/baseline-suppressions.js'; -import { formatSarif } from '../src/core-open/formatters/sarif.js'; -import { generateVulnerabilityFindings, scanVulnerabilities } from '../src/core-open/index.js'; -import type { PackageVersionManifest } from '../src/core-open/package-version-manifest.js'; -import type { Finding, ScanArtifact, SecurityFinding, SecuritySection } from '../src/core-open/types.js'; -import { Semaphore } from '../src/core-open/utils/semaphore.js'; - -const FINGERPRINT_KEY = 'vg/finding-id/v1'; - -interface SarifResult { - ruleId: string; - message: { text: string }; - locations: Array<{ physicalLocation: { artifactLocation: { uri: string } } }>; - partialFingerprints?: Record; - properties?: Record; - suppressions?: Array<{ properties?: { id?: string } }>; -} - -interface SarifDoc { - runs: Array<{ - results: SarifResult[]; - invocations?: Array<{ startTimeUtc?: string }>; - }>; -} - -/** Manifest order is deliberately not the documented SARIF order. */ -const MANIFEST: PackageVersionManifest = { - npm: { - lodash: { - vulns: [ - { - id: 'CVE-2024-1111', - aliases: ['GHSA-bbbb'], - severity: 'high', - cvss: 7.5, - ranges: [{ introduced: '0', fixed: '4.17.21' }], - }, - { - id: 'GHSA-bbbb', - aliases: ['CVE-2024-1111', 'OSV-1'], - severity: 'critical', - cvss: 9.1, - ranges: [{ introduced: '0', fixed: '4.17.21' }], - }, - ], - }, - }, -}; - -const DRIFT: Finding[] = [ - { - ruleId: 'vibgrate/runtime-lag', - level: 'warning', - message: 'Node.js runtime ">=20.0.0" is 2 major versions behind (latest: 22.0.0).', - location: 'app', - }, - { - ruleId: 'vibgrate/dependency-major-lag', - level: 'error', - message: 'chalk is 3 major versions behind (spec: ^4.0.0, latest: 5.4.0).', - location: 'app', - }, - { - ruleId: 'vibgrate/dependency-major-lag', - level: 'error', - message: 'left-pad is 4 major versions behind (spec: ^0.0.1, latest: 1.3.0).', - location: 'app', - }, - { - ruleId: 'vibgrate/license-parse-failed', - level: 'warning', - message: 'License "Also-Not-SPDX" on left-pad could not be resolved as SPDX.', - location: 'package.json', - details: { sourcePath: 'package.json', raw: 'Also-Not-SPDX' }, - }, -]; - -const SECURITY: SecurityFinding[] = [ - { - id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - pack: 'iac-cis-v1', - packVersion: '1', - rule: 'aws-s3-public', - severity: 'high', - message: 'S3 bucket logs allows public access.', - path: 'infra/s3.tf', - line: 1, - address: 'aws_s3_bucket.logs', - }, - { - id: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - pack: 'iac-cis-v1', - packVersion: '1', - rule: 'aws-s3-public', - severity: 'high', - message: 'S3 bucket uploads allows public access.', - path: 'infra/s3.tf', - line: 8, - address: 'aws_s3_bucket.uploads', - }, -]; - -function securitySection(findings: SecurityFinding[]): SecuritySection { - return { - schema: 'vg.security.v1', - engine: 'test-engine', - packs: { 'iac-cis-v1': '1' }, - facts: { received: findings.length, evaluated: findings.length, rejected: 0 }, - findings, - }; -} - -function artifact(options: { - timestamp: string; - findings: Finding[]; - security?: SecurityFinding[]; - baselineId?: { ruleId: string; location: string; id: string }; -}): ScanArtifact { - return { - schemaVersion: '1.0', - timestamp: options.timestamp, - vibgrateVersion: '0.0.0-test', - rootPath: '.', - projects: [], - drift: { - score: 0, - riskLevel: 'low', - components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, - }, - findings: options.findings, - ...(options.baselineId - ? { - baselineComparison: { - compared: true, - suppressedCount: 1, - suppressed: [options.baselineId], - }, - } - : {}), - ...(options.security - ? { extended: { security: securitySection(options.security) } } - : {}), - }; -} - -function fingerprint(result: SarifResult): string { - const value = result.partialFingerprints?.[FINGERPRINT_KEY]; - if (!value) throw new Error(`missing ${FINGERPRINT_KEY} on ${result.ruleId}`); - return value; -} - -/** Identity that ignores result order, message wording, and detail key order. */ -function advisoryIdentity(result: SarifResult): string | undefined { - const advisoryId = result.properties?.advisoryId; - if (typeof advisoryId !== 'string' || advisoryId.length === 0) return undefined; - return [ - 'advisory', - result.ruleId, - result.locations[0]?.physicalLocation.artifactLocation.uri ?? '', - String(result.properties?.ecosystem ?? ''), - String(result.properties?.package ?? ''), - String(result.properties?.installedVersion ?? ''), - advisoryId, - ].join('\n'); -} - -function sortKeys(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sortKeys); - if (value && typeof value === 'object') { - const out: Record = {}; - for (const key of Object.keys(value as Record).sort()) { - out[key] = sortKeys((value as Record)[key]); - } - return out; - } - return value; -} - -function driftIdentity(result: SarifResult): string { - return [ - 'drift', - result.ruleId, - result.locations[0]?.physicalLocation.artifactLocation.uri ?? '', - result.message.text, - JSON.stringify(sortKeys(result.properties ?? null)), - ].join('\n'); -} - -function indexFingerprints(doc: SarifDoc): Map { - const index = new Map(); - for (const result of doc.runs[0]?.results ?? []) { - const key = advisoryIdentity(result) ?? driftIdentity(result); - index.set(key, fingerprint(result)); - } - return index; -} - -function reverseKeys(details: Record): Record { - const out: Record = {}; - for (const key of Object.keys(details).reverse()) out[key] = details[key]; - return out; -} - -function withUnstableSurface(finding: Finding): Finding { - if (typeof finding.details?.advisoryId !== 'string') { - if (!finding.details) return finding; - return { ...finding, details: reverseKeys(finding.details) }; - } - const aliases = finding.details.aliases; - return { - ...finding, - message: `${finding.message} — introduced by Ada in abcdef (9d exposed)`, - details: reverseKeys({ - ...finding.details, - aliases: Array.isArray(aliases) ? [...aliases].reverse() : aliases, - exposureDays: 9, - introducedDate: '2020-01-02', - }), - }; -} - -describe('vg scan SARIF partialFingerprints', () => { - it('matches fingerprints when only the timestamp and finding order change', async () => { - const scanned = await scanVulnerabilities( - [{ ecosystem: 'npm', package: 'lodash', version: '4.17.20' }], - { sem: new Semaphore(1), offline: true, manifest: MANIFEST }, - ); - const vulns = generateVulnerabilityFindings(scanned); - const findings = [...DRIFT, ...vulns]; - const first = artifact({ - timestamp: '2026-01-01T00:00:00.000Z', - findings, - security: SECURITY, - }); - const second = artifact({ - timestamp: '2026-06-15T12:34:56.000Z', - findings: [...findings].reverse().map(withUnstableSurface), - security: [...SECURITY].reverse(), - }); - - const sarifA = formatSarif(first) as SarifDoc; - const sarifB = formatSarif(second) as SarifDoc; - expect(formatSarif(first)).toEqual(sarifA); - - expect(sarifA.runs[0]?.invocations?.[0]?.startTimeUtc).toBe('2026-01-01T00:00:00.000Z'); - expect(sarifB.runs[0]?.invocations?.[0]?.startTimeUtc).toBe('2026-06-15T12:34:56.000Z'); - expect(sarifA.runs[0]?.results.map((result) => result.ruleId)).not.toEqual( - sarifB.runs[0]?.results.map((result) => result.ruleId), - ); - - const indexA = indexFingerprints(sarifA); - const indexB = indexFingerprints(sarifB); - expect(indexB).toEqual(indexA); - - for (const doc of [sarifA, sarifB]) { - const results = doc.runs.flatMap((run) => run.results); - expect(results.length).toBeGreaterThan(0); - for (const result of results) { - expect(fingerprint(result)).toMatch(/^[0-9a-f]{32}$/); - } - } - - // The day count stays in the message. It does not move the fingerprint. - const exposed = sarifB.runs[0]?.results.find((result) => result.properties?.advisoryId === 'GHSA-bbbb'); - const original = sarifA.runs[0]?.results.find((result) => result.properties?.advisoryId === 'GHSA-bbbb'); - expect(exposed?.message.text).toContain('(9d exposed)'); - expect(original?.message.text).not.toContain('exposed'); - expect(fingerprint(exposed!)).toBe(fingerprint(original!)); - - const securityA = sarifA.runs[1]?.results.map(fingerprint).sort(); - const securityB = sarifB.runs[1]?.results.map(fingerprint).sort(); - expect(securityA).toEqual(['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb']); - expect(securityB).toEqual(securityA); - }); - - it('gives distinct fingerprints to distinct findings', async () => { - const scanned = await scanVulnerabilities( - [{ ecosystem: 'npm', package: 'lodash', version: '4.17.20' }], - { sem: new Semaphore(1), offline: true, manifest: MANIFEST }, - ); - const findings = [...DRIFT, ...generateVulnerabilityFindings(scanned)]; - const sarif = formatSarif(artifact({ - timestamp: '2026-01-01T00:00:00.000Z', - findings, - security: SECURITY, - })) as SarifDoc; - - const drift = sarif.runs[0]?.results ?? []; - const byMessage = new Map(drift.map((result) => [result.message.text, fingerprint(result)])); - const chalk = byMessage.get(DRIFT[1]!.message); - const leftPad = byMessage.get(DRIFT[2]!.message); - expect(chalk).toBeDefined(); - expect(leftPad).toBeDefined(); - expect(chalk).not.toBe(leftPad); - expect(byMessage.get(DRIFT[0]!.message)).not.toBe(chalk); - - const ghsa = drift.find((result) => result.properties?.advisoryId === 'GHSA-bbbb'); - const cve = drift.find((result) => result.properties?.advisoryId === 'CVE-2024-1111'); - expect(ghsa?.message.text).toBe( - 'lodash@4.17.20: GHSA-bbbb (CVE-2024-1111) (critical 9.1) — fix available (4.17.21)', - ); - // Documented in DOCS.md under SARIF result identity. - expect(fingerprint(ghsa!)).toBe('dd5a118102af5cbdee3ada40cbaf3021'); - expect(fingerprint(ghsa!)).not.toBe(fingerprint(cve!)); - - const all = sarif.runs.flatMap((run) => run.results.map(fingerprint)); - expect(new Set(all).size).toBe(all.length); - }); - - it('keeps the baseline suppression id on vg/finding-id/v1', () => { - const finding = DRIFT[0]!; - const id = baselineSuppressionId(finding.ruleId, finding.location); - const suppressed = formatSarif(artifact({ - timestamp: '2026-01-01T00:00:00.000Z', - findings: [finding, DRIFT[1]!], - baselineId: { ruleId: finding.ruleId, location: finding.location, id }, - })) as SarifDoc; - const again = formatSarif(artifact({ - timestamp: '2026-08-01T00:00:00.000Z', - findings: [DRIFT[1]!, finding], - baselineId: { ruleId: finding.ruleId, location: finding.location, id }, - })) as SarifDoc; - - const match = (doc: SarifDoc) => doc.runs[0]?.results.find((result) => result.ruleId === finding.ruleId); - expect(match(suppressed)?.message.text).toBe(finding.message); - expect(fingerprint(match(suppressed)!)).toBe(id); - expect(match(suppressed)?.suppressions?.[0]?.properties?.id).toBe(id); - expect(fingerprint(match(again)!)).toBe(id); - - const open = formatSarif(artifact({ - timestamp: '2026-01-01T00:00:00.000Z', - findings: [finding], - })) as SarifDoc; - expect(fingerprint(open.runs[0]!.results[0]!)).not.toBe(id); - expect(open.runs[0]!.results[0]?.suppressions).toBeUndefined(); - }); -});