Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ backward compatible.
default. There is no HTML report. Markdown, text, and JSON field coverage
is documented on `vg report`.

- **`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
`vibgrate:projects` lists every project that contributed it. A sub-project
Expand Down
24 changes: 23 additions & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,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)
Expand Down Expand Up @@ -4753,10 +4754,27 @@ 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 fingerprints

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 | 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 <number> 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 contents produce the same value. A different finding produces a different value.

#### 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.
Expand All @@ -4769,13 +4787,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 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
{
Expand All @@ -4791,6 +4810,9 @@ The ids in this example show the field shape. A real scan fills them from the ad
}
}
],
"partialFingerprints": {
"vg/finding-id/v1": "82efba63199c173114225d3195aa15aa"
},
"properties": {
"ecosystem": "npm",
"package": "lodash",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 83 additions & 8 deletions src/core-open/formatters/sarif.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
// 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 { baselineSuppressionId } from '../baseline-suppressions.js';
import type { BaselineSuppression, ScanArtifact, Finding, SecurityFinding, SecuritySection, SecuritySeverity } from '../types.js';

/**
* 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';

/**
* 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 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 is still one run,
* and an empty pack section does not change the document.
*/
export function formatSarif(artifact: ScanArtifact): object {
const suppressed = suppressionIndex(artifact);
Expand Down Expand Up @@ -105,7 +114,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 ?? '',
Expand Down Expand Up @@ -198,6 +207,67 @@ function suppressionIndex(artifact: ScanArtifact): Map<string, BaselineSuppressi
return index;
}

/** Detail fields that identify a finding. Scores, aliases, and versions are not identity. */
const IDENTITY_DETAIL_KEYS = ['advisoryId', 'ecosystem', 'package', 'purl', 'raw'] as const;

/**
* Rules that can emit more than one finding at the same path, with the
* subject named only in the message: `<name> is <n> 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,
Expand All @@ -212,14 +282,19 @@ function toSarifResult(finding: Finding, suppression?: BaselineSuppression) {
},
},
],
// 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),
},
// 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 } : {}),
// The result stays in the run. The suppression cites the same id as
// `baselineComparison.suppressed`, so a baseline match is not dropped.
...(suppression
? {
partialFingerprints: { 'vg/finding-id/v1': suppression.id },
suppressions: [
{
kind: 'external',
Expand Down
Loading
Loading