diff --git a/DOCS.md b/DOCS.md index f31fd5c0..272e7b65 100644 --- a/DOCS.md +++ b/DOCS.md @@ -4583,6 +4583,20 @@ The ids in this example show the field shape. A real scan fills them from the ad The same package can also carry `CVE-2024-1111` as its own advisory, with alias `GHSA-bbbb`. That is a second result. Its `properties.advisoryId` is `CVE-2024-1111`, its message names that CVE on its own (the id is already the advisory id), and `properties.aliases` is `["GHSA-bbbb"]`. +#### Severity and `level` + +Each vulnerability result's `level` follows the advisory severity, which the result also carries in `properties.severity`: + +| `properties.severity` | `level` | +| --------------------- | ------- | +| `critical` | `error` | +| `high` | `error` | +| `moderate` | `warning` | +| `low` | `note` | +| `unknown` | `note` | + +`unknown` means the advisory data gave no usable severity, for example no severity label and no CVSS score. It gets `note`, the same as `low`, so an unscored advisory never outranks a scored one. Read `properties.severity` to tell the two apart. The `vibgrate/vulnerability` rule carries no `security-severity` property, so GitHub code scanning shows these alerts as Error, Warning, or Note from `level`. + Test reporters that ingest JUnit can take a companion file from the same scan. See [JUnit](#junit). The process exit code is unchanged either way; see [Exit Codes](#exit-codes). ### Markdown diff --git a/src/reporting/formatters/sarif-vulnerability-level.test.ts b/src/reporting/formatters/sarif-vulnerability-level.test.ts new file mode 100644 index 00000000..b3a092b6 --- /dev/null +++ b/src/reporting/formatters/sarif-vulnerability-level.test.ts @@ -0,0 +1,73 @@ +// `vg scan --vulns --format sarif` sets each vulnerability result's `level` +// from the advisory severity. GitHub code scanning uses `level` to decide how +// loudly to show an alert, so the mapping is pinned here and in the SARIF +// section of DOCS.md. Advisories come from an offline manifest: no network. +import { describe, expect, it } from 'vitest'; +import { formatSarif } from '../../core-open/formatters/sarif.js'; +import type { ManifestAdvisory, PackageVersionManifest } from '../../core-open/package-version-manifest.js'; +import { generateVulnerabilityFindings, scanVulnerabilities } from '../../core-open/scanners/vulnerability-scanner.js'; +import type { ScanArtifact } from '../../core-open/types.js'; +import { Semaphore } from '../../core-open/utils/semaphore.js'; + +const advisory = (id: string, fields: Partial): ManifestAdvisory => ({ id, versions: ['1.0.0'], ...fields }); + +const manifest: PackageVersionManifest = { + npm: { + 'left-pad': { + vulns: [ + advisory('VG-TEST-LOW', { severity: 'low' }), + advisory('VG-TEST-UNSCORED', {}), + advisory('VG-TEST-CRITICAL', { severity: 'critical' }), + advisory('VG-TEST-MODERATE', { severity: 'moderate' }), + advisory('VG-TEST-CVSS-ONLY', { cvss: 7.5 }), + advisory('VG-TEST-HIGH', { severity: 'high' }), + ], + }, + }, +}; + +async function sarifResults() { + const scan = await scanVulnerabilities([{ ecosystem: 'npm', package: 'left-pad', version: '1.0.0' }], { + sem: new Semaphore(1), + offline: true, + manifest, + }); + const artifact = { findings: generateVulnerabilityFindings(scan) } as unknown as ScanArtifact; + const sarif = formatSarif(artifact) as { + runs: Array<{ results: Array<{ ruleId: string; level: string; properties: { advisoryId: string; severity: string } }> }>; + }; + return sarif.runs[0].results; +} + +describe('SARIF level for vulnerability findings', () => { + it('maps critical and high to error, moderate to warning, low and unscored to note', async () => { + const rows = (await sarifResults()).map((r) => ({ + advisoryId: r.properties.advisoryId, + severity: r.properties.severity, + level: r.level, + })); + + expect(rows).toEqual([ + { advisoryId: 'VG-TEST-CRITICAL', severity: 'critical', level: 'error' }, + { advisoryId: 'VG-TEST-CVSS-ONLY', severity: 'high', level: 'error' }, + { advisoryId: 'VG-TEST-HIGH', severity: 'high', level: 'error' }, + { advisoryId: 'VG-TEST-MODERATE', severity: 'moderate', level: 'warning' }, + { advisoryId: 'VG-TEST-LOW', severity: 'low', level: 'note' }, + { advisoryId: 'VG-TEST-UNSCORED', severity: 'unknown', level: 'note' }, + ]); + }); + + it('keeps the unscored advisory distinguishable from a low one', async () => { + const results = await sarifResults(); + const low = results.find((r) => r.properties.advisoryId === 'VG-TEST-LOW'); + const unscored = results.find((r) => r.properties.advisoryId === 'VG-TEST-UNSCORED'); + + expect(low?.level).toBe(unscored?.level); + expect(low?.properties.severity).toBe('low'); + expect(unscored?.properties.severity).toBe('unknown'); + }); + + it('produces the same results on every run', async () => { + expect(JSON.stringify(await sarifResults())).toBe(JSON.stringify(await sarifResults())); + }); +});