From ff7d0f28464c9b70249d5c37e0bfcd4f2833f443 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Sat, 11 Jul 2026 09:00:00 -0500 Subject: [PATCH 1/4] feat: add ruleIndex, kind, partialFingerprints to SARIF CVE results --- src/output/sarif.ts | 24 +++++++++++++++++++-- tests/sarif.test.ts | 52 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/output/sarif.ts b/src/output/sarif.ts index 4adb8e68..f21c2bc6 100644 --- a/src/output/sarif.ts +++ b/src/output/sarif.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { createHash } from "node:crypto"; import type { Finding, OsvVuln, ScanSource } from "../types.js"; import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; import type { OverrideFinding } from "../overrides/types.js"; @@ -8,6 +9,8 @@ import { getCliVersion } from "../utils/version-info.js"; import { severityToSarifLevel } from "../utils/severity.js"; import { buildOverrideSarifComponent, buildOverrideSarifResults } from "./override-findings-sarif.js"; +const SARIF_SCHEMA = "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json"; + type SarifLog = { $schema: string; version: "2.1.0"; @@ -40,9 +43,12 @@ type SarifRule = { type SarifResult = { ruleId: string; + ruleIndex: number; + kind: "open"; level: "error" | "warning" | "note"; message: { text: string }; locations: SarifLocation[]; + partialFingerprints: { primaryLocationLineHash: string }; }; type SarifLocation = { @@ -66,6 +72,13 @@ const LOCKFILE_NAMES: Record = { "unknown": "lockfile", }; +function fingerprintHash(ruleId: string, pkgName: string, pkgVersion: string): string { + return createHash("sha256") + .update(`${ruleId}:${pkgName}@${pkgVersion}`) + .digest("hex") + .slice(0, 16); +} + function findVulnForRuleId(ruleId: string, vulns: OsvVuln[]): OsvVuln | undefined { return vulns.find(v => v.id === ruleId || v.aliases?.includes(ruleId)); } @@ -135,6 +148,7 @@ export function buildSarifOutput( overrideFindings?: ReadonlyArray, ): SarifLog { const ruleMap = new Map(); + const ruleIndexMap = new Map(); const results: SarifResult[] = []; for (const finding of findings) { @@ -154,6 +168,7 @@ export function buildSarifOutput( for (const ruleId of ruleIds) { if (!ruleMap.has(ruleId)) { + ruleIndexMap.set(ruleId, ruleMap.size); const vuln = findVulnForRuleId(ruleId, finding.vulnerabilities); const summary = vuln?.summary; const details = vuln?.details; @@ -174,11 +189,16 @@ export function buildSarifOutput( results.push({ ruleId, + ruleIndex: ruleIndexMap.get(ruleId)!, + kind: "open", level, message: { text: `${finding.pkg.name}@${finding.pkg.version} is vulnerable (${finding.severity}). ${action}`, }, locations: [location], + partialFingerprints: { + primaryLocationLineHash: fingerprintHash(ruleId, finding.pkg.name, finding.pkg.version), + }, }); } } @@ -189,11 +209,11 @@ export function buildSarifOutput( const allResults = [ ...results, - ...(overrideFindings ? (buildOverrideSarifResults(overrideFindings) as SarifResult[]) : []), + ...(overrideFindings ? (buildOverrideSarifResults(overrideFindings) as unknown as SarifResult[]) : []), ]; return { - $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + $schema: SARIF_SCHEMA, version: "2.1.0", runs: [ { diff --git a/tests/sarif.test.ts b/tests/sarif.test.ts index 01177551..0842e871 100644 --- a/tests/sarif.test.ts +++ b/tests/sarif.test.ts @@ -157,3 +157,55 @@ describe("buildSarifOutput — graceful degradation", () => { expect(rule.help?.markdown).not.toContain("**Patched version:**"); }); }); + +describe("buildSarifOutput — SARIF 2.1.0 result fields", () => { + let sarif: ReturnType; + + beforeAll(() => { + sarif = buildSarifOutput([makeEnrichedFinding()], "package-lock.json", "1.0.0", null); + }); + + it("result has kind: open", () => { + const result = sarif.runs[0]!.results[0]!; + expect((result as any).kind).toBe("open"); + }); + + it("result has ruleIndex matching its position in driver.rules", () => { + const result = sarif.runs[0]!.results[0]! as any; + const rules = sarif.runs[0]!.tool.driver.rules; + expect(typeof result.ruleIndex).toBe("number"); + expect(rules[result.ruleIndex]).toBeDefined(); + expect(rules[result.ruleIndex]!.id).toBe(result.ruleId); + }); + + it("result has partialFingerprints.primaryLocationLineHash as a non-empty hex string", () => { + const result = sarif.runs[0]!.results[0]! as any; + const hash = result.partialFingerprints?.primaryLocationLineHash; + expect(typeof hash).toBe("string"); + expect(hash.length).toBeGreaterThan(0); + expect(/^[0-9a-f]+$/.test(hash)).toBe(true); + }); + + it("primaryLocationLineHash is stable for the same ruleId+pkg across calls", () => { + const sarif2 = buildSarifOutput([makeEnrichedFinding()], "package-lock.json", "1.0.0", null); + const h1 = (sarif.runs[0]!.results[0]! as any).partialFingerprints.primaryLocationLineHash; + const h2 = (sarif2.runs[0]!.results[0]! as any).partialFingerprints.primaryLocationLineHash; + expect(h1).toBe(h2); + }); + + it("primaryLocationLineHash differs for different ruleId+pkg combinations", () => { + const sarif2 = buildSarifOutput( + [makeEnrichedFinding({ pkg: { name: "other-pkg", version: "1.0.0", ecosystem: "npm", paths: [] } })], + "package-lock.json", "1.0.0", null, + ); + const h1 = (sarif.runs[0]!.results[0]! as any).partialFingerprints.primaryLocationLineHash; + const h2 = (sarif2.runs[0]!.results[0]! as any).partialFingerprints.primaryLocationLineHash; + expect(h1).not.toBe(h2); + }); + + it("$schema points to the stable OASIS errata URL", () => { + expect(sarif.$schema).toBe( + "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json" + ); + }); +}); From ca286c0dd94007d654fae690ee8b6958403d1120 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Sat, 11 Jul 2026 09:07:00 -0500 Subject: [PATCH 2/4] feat: add ruleIndex, kind, partialFingerprints to SARIF override results --- src/output/override-findings-sarif.ts | 20 ++++++- src/output/sarif.ts | 2 +- tests/output/override-findings-sarif.test.ts | 55 ++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/output/override-findings-sarif.ts b/src/output/override-findings-sarif.ts index 49662397..8814efe3 100644 --- a/src/output/override-findings-sarif.ts +++ b/src/output/override-findings-sarif.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { OverrideFinding } from "../overrides/types.js"; import { getCliVersion } from "../utils/version-info.js"; @@ -15,6 +16,8 @@ const OA_RULES: Array<{ id: string; name: string; shortDescription: string; help { id: "PD002", name: "TransitiveOnlyPhantom", shortDescription: "Package imported in source but only present as a transitive dependency - declare it explicitly", helpUri: "https://github.com/OWASP/cve-lite-cli/blob/main/docs/rules/PD002.md" }, ]; +const OA_RULE_INDEX = new Map(OA_RULES.map((r, i) => [r.id, i])); + const SEVERITY_TO_LEVEL: Record = { critical: "error", high: "error", @@ -23,6 +26,13 @@ const SEVERITY_TO_LEVEL: Record = { info: "note", }; +function overrideFingerprintHash(ruleId: string, pkgName: string): string { + return createHash("sha256") + .update(`${ruleId}:${pkgName}`) + .digest("hex") + .slice(0, 16); +} + export function buildOverrideSarifComponent(): unknown { return { name: "cve-lite-cli-overrides", @@ -39,12 +49,14 @@ export function buildOverrideSarifComponent(): unknown { export interface OverrideSarifResult { ruleId: string; + ruleIndex: number; // SARIF 2.1.0 §3.27.7: results that reference a rule from an extension // component must carry a reportingDescriptorReference with the toolComponent // index, or consumers (GitHub Code Scanning, SARIF Multitool) cannot link the - // result back to OA001-OA008 and validation fails. Index 0 = the (only) entry + // result back to OA001-OA009 and validation fails. Index 0 = the (only) entry // in the run's tool.extensions array. rule: { id: string; toolComponent: { index: number } }; + kind: "open"; level: "error" | "warning" | "note"; message: { text: string }; locations: Array<{ @@ -53,6 +65,7 @@ export interface OverrideSarifResult { region: { startLine: number }; }; }>; + partialFingerprints: { primaryLocationLineHash: string }; properties?: Record; } @@ -61,7 +74,9 @@ export function buildOverrideSarifResults( ): OverrideSarifResult[] { return findings.map((f) => ({ ruleId: f.ruleId, + ruleIndex: OA_RULE_INDEX.get(f.ruleId) ?? 0, rule: { id: f.ruleId, toolComponent: { index: 0 } }, + kind: "open", level: SEVERITY_TO_LEVEL[f.severity] ?? "warning", message: { text: f.message }, locations: [ @@ -72,6 +87,9 @@ export function buildOverrideSarifResults( }, }, ], + partialFingerprints: { + primaryLocationLineHash: overrideFingerprintHash(f.ruleId, f.package.name), + }, properties: { package: f.package.name, jsonPath: f.location.jsonPath, diff --git a/src/output/sarif.ts b/src/output/sarif.ts index f21c2bc6..cf863cbd 100644 --- a/src/output/sarif.ts +++ b/src/output/sarif.ts @@ -209,7 +209,7 @@ export function buildSarifOutput( const allResults = [ ...results, - ...(overrideFindings ? (buildOverrideSarifResults(overrideFindings) as unknown as SarifResult[]) : []), + ...(overrideFindings ? (buildOverrideSarifResults(overrideFindings) as SarifResult[]) : []), ]; return { diff --git a/tests/output/override-findings-sarif.test.ts b/tests/output/override-findings-sarif.test.ts index 1b455dc6..0b7a4110 100644 --- a/tests/output/override-findings-sarif.test.ts +++ b/tests/output/override-findings-sarif.test.ts @@ -43,3 +43,58 @@ describe("override-findings-sarif", () => { expect(c.version).not.toBe("1.0.0"); }); }); + +describe("override-findings-sarif — SARIF 2.1.0 result fields", () => { + const finding: OverrideFinding = { + ruleId: "OA001", + severity: "high", + package: { name: "postcss" }, + location: { file: "package.json", jsonPath: "/overrides/postcss" }, + message: "Override target not in resolved tree", + }; + + it("result has kind: open", () => { + const [result] = buildOverrideSarifResults([finding]); + expect(result.kind).toBe("open"); + }); + + it("result has ruleIndex matching OA001 position in OA_RULES", () => { + const [result] = buildOverrideSarifResults([finding]); + // OA001 is first in OA_RULES array + expect(result.ruleIndex).toBe(0); + }); + + it("PD001 ruleIndex is 9 (10th entry in OA_RULES)", () => { + const pd1Finding: OverrideFinding = { + ruleId: "PD001", + severity: "high", + package: { name: "react" }, + location: { file: "package.json", jsonPath: "/overrides/react" }, + message: "Phantom import", + }; + const [result] = buildOverrideSarifResults([pd1Finding]); + expect(result.ruleIndex).toBe(9); + }); + + it("result has partialFingerprints.primaryLocationLineHash as a non-empty hex string", () => { + const [result] = buildOverrideSarifResults([finding]); + const hash = result.partialFingerprints.primaryLocationLineHash; + expect(typeof hash).toBe("string"); + expect(hash.length).toBeGreaterThan(0); + expect(/^[0-9a-f]+$/.test(hash)).toBe(true); + }); + + it("primaryLocationLineHash is stable for the same ruleId+pkg", () => { + const [r1] = buildOverrideSarifResults([finding]); + const [r2] = buildOverrideSarifResults([finding]); + expect(r1.partialFingerprints.primaryLocationLineHash) + .toBe(r2.partialFingerprints.primaryLocationLineHash); + }); + + it("buildOverrideSarifComponent registers PD001 and PD002", () => { + const c = buildOverrideSarifComponent(); + const ruleIds = (c as any).rules?.map((r: any) => r.id); + expect(ruleIds).toContain("PD001"); + expect(ruleIds).toContain("PD002"); + }); +}); From 03b81573b39c51ed0e833e53577d317f2cd3f477 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Sat, 11 Jul 2026 09:13:00 -0500 Subject: [PATCH 3/4] fix: throw on unknown ruleId in override SARIF ruleIndex lookup --- src/output/override-findings-sarif.ts | 50 +++++++++++--------- tests/output/override-findings-sarif.test.ts | 11 +++++ 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/src/output/override-findings-sarif.ts b/src/output/override-findings-sarif.ts index 8814efe3..d9e00b23 100644 --- a/src/output/override-findings-sarif.ts +++ b/src/output/override-findings-sarif.ts @@ -72,28 +72,34 @@ export interface OverrideSarifResult { export function buildOverrideSarifResults( findings: ReadonlyArray, ): OverrideSarifResult[] { - return findings.map((f) => ({ - ruleId: f.ruleId, - ruleIndex: OA_RULE_INDEX.get(f.ruleId) ?? 0, - rule: { id: f.ruleId, toolComponent: { index: 0 } }, - kind: "open", - level: SEVERITY_TO_LEVEL[f.severity] ?? "warning", - message: { text: f.message }, - locations: [ - { - physicalLocation: { - artifactLocation: { uri: f.location.file, uriBaseId: "%SRCROOT%" }, - region: { startLine: f.location.line ?? 1 }, + return findings.map((f) => { + const ruleIndex = OA_RULE_INDEX.get(f.ruleId); + if (ruleIndex === undefined) { + throw new Error(`OA_RULE_INDEX: unknown ruleId "${f.ruleId}" - add it to OA_RULES`); + } + return { + ruleId: f.ruleId, + ruleIndex, + rule: { id: f.ruleId, toolComponent: { index: 0 } }, + kind: "open", + level: SEVERITY_TO_LEVEL[f.severity] ?? "warning", + message: { text: f.message }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: f.location.file, uriBaseId: "%SRCROOT%" }, + region: { startLine: f.location.line ?? 1 }, + }, }, + ], + partialFingerprints: { + primaryLocationLineHash: overrideFingerprintHash(f.ruleId, f.package.name), }, - ], - partialFingerprints: { - primaryLocationLineHash: overrideFingerprintHash(f.ruleId, f.package.name), - }, - properties: { - package: f.package.name, - jsonPath: f.location.jsonPath, - severity: f.severity, - }, - })); + properties: { + package: f.package.name, + jsonPath: f.location.jsonPath, + severity: f.severity, + }, + }; + }); } diff --git a/tests/output/override-findings-sarif.test.ts b/tests/output/override-findings-sarif.test.ts index 0b7a4110..2501f07d 100644 --- a/tests/output/override-findings-sarif.test.ts +++ b/tests/output/override-findings-sarif.test.ts @@ -97,4 +97,15 @@ describe("override-findings-sarif — SARIF 2.1.0 result fields", () => { expect(ruleIds).toContain("PD001"); expect(ruleIds).toContain("PD002"); }); + + it("throws for an unregistered ruleId", () => { + const bad: OverrideFinding = { + ruleId: "OA999", + severity: "high", + package: { name: "x" }, + location: { file: "package.json" }, + message: "x", + }; + expect(() => buildOverrideSarifResults([bad])).toThrow(/unknown ruleId/); + }); }); From 472ea2a484929434a1350e3de74013ab68d5d989 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Sat, 11 Jul 2026 09:19:00 -0500 Subject: [PATCH 4/4] fix: address final review findings - dedup severity util, align guards, monorepo fingerprint, remove defaultConfiguration.level --- src/output/override-findings-sarif.ts | 22 ++++---------------- src/output/sarif.ts | 22 ++++++-------------- src/utils/sarif.ts | 5 +++++ tests/output/override-findings-sarif.test.ts | 2 +- tests/sarif.test.ts | 2 +- 5 files changed, 17 insertions(+), 36 deletions(-) create mode 100644 src/utils/sarif.ts diff --git a/src/output/override-findings-sarif.ts b/src/output/override-findings-sarif.ts index d9e00b23..f0a2615b 100644 --- a/src/output/override-findings-sarif.ts +++ b/src/output/override-findings-sarif.ts @@ -1,6 +1,7 @@ -import { createHash } from "node:crypto"; import type { OverrideFinding } from "../overrides/types.js"; import { getCliVersion } from "../utils/version-info.js"; +import { severityToSarifLevel } from "../utils/severity.js"; +import { sarifFingerprintHash } from "../utils/sarif.js"; const OA_RULES: Array<{ id: string; name: string; shortDescription: string; helpUri: string }> = [ { id: "OA001", name: "OrphanedTarget", shortDescription: "Override target not in resolved tree", helpUri: "https://github.com/OWASP/cve-lite-cli/blob/main/docs/rules/OA001.md" }, @@ -18,21 +19,6 @@ const OA_RULES: Array<{ id: string; name: string; shortDescription: string; help const OA_RULE_INDEX = new Map(OA_RULES.map((r, i) => [r.id, i])); -const SEVERITY_TO_LEVEL: Record = { - critical: "error", - high: "error", - medium: "warning", - low: "note", - info: "note", -}; - -function overrideFingerprintHash(ruleId: string, pkgName: string): string { - return createHash("sha256") - .update(`${ruleId}:${pkgName}`) - .digest("hex") - .slice(0, 16); -} - export function buildOverrideSarifComponent(): unknown { return { name: "cve-lite-cli-overrides", @@ -82,7 +68,7 @@ export function buildOverrideSarifResults( ruleIndex, rule: { id: f.ruleId, toolComponent: { index: 0 } }, kind: "open", - level: SEVERITY_TO_LEVEL[f.severity] ?? "warning", + level: severityToSarifLevel(f.severity), message: { text: f.message }, locations: [ { @@ -93,7 +79,7 @@ export function buildOverrideSarifResults( }, ], partialFingerprints: { - primaryLocationLineHash: overrideFingerprintHash(f.ruleId, f.package.name), + primaryLocationLineHash: sarifFingerprintHash(`${f.ruleId}:${f.package.name}:${f.location.file}`), }, properties: { package: f.package.name, diff --git a/src/output/sarif.ts b/src/output/sarif.ts index cf863cbd..a6b5c7c1 100644 --- a/src/output/sarif.ts +++ b/src/output/sarif.ts @@ -1,12 +1,12 @@ import fs from "node:fs"; import path from "node:path"; -import { createHash } from "node:crypto"; import type { Finding, OsvVuln, ScanSource } from "../types.js"; import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; import type { OverrideFinding } from "../overrides/types.js"; import { getRecommendedAction } from "./formatters.js"; import { getCliVersion } from "../utils/version-info.js"; import { severityToSarifLevel } from "../utils/severity.js"; +import { sarifFingerprintHash } from "../utils/sarif.js"; import { buildOverrideSarifComponent, buildOverrideSarifResults } from "./override-findings-sarif.js"; const SARIF_SCHEMA = "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json"; @@ -37,7 +37,6 @@ type SarifRule = { fullDescription: { text: string }; help?: { text: string; markdown: string }; helpUri: string; - defaultConfiguration: { level: "error" | "warning" | "note" }; properties: { tags: string[] }; }; @@ -72,13 +71,6 @@ const LOCKFILE_NAMES: Record = { "unknown": "lockfile", }; -function fingerprintHash(ruleId: string, pkgName: string, pkgVersion: string): string { - return createHash("sha256") - .update(`${ruleId}:${pkgName}@${pkgVersion}`) - .digest("hex") - .slice(0, 16); -} - function findVulnForRuleId(ruleId: string, vulns: OsvVuln[]): OsvVuln | undefined { return vulns.find(v => v.id === ruleId || v.aliases?.includes(ruleId)); } @@ -168,6 +160,7 @@ export function buildSarifOutput( for (const ruleId of ruleIds) { if (!ruleMap.has(ruleId)) { + // ruleMap.size before insertion = 0-based index of the new entry ruleIndexMap.set(ruleId, ruleMap.size); const vuln = findVulnForRuleId(ruleId, finding.vulnerabilities); const summary = vuln?.summary; @@ -182,7 +175,6 @@ export function buildSarifOutput( fullDescription: { text: details ?? summary ?? `Vulnerable dependency: ${ruleId}` }, ...(help !== undefined ? { help } : {}), helpUri: `https://osv.dev/vulnerability/${ruleId}`, - defaultConfiguration: { level }, properties: { tags: ["security", "dependency"] }, }); } @@ -197,19 +189,17 @@ export function buildSarifOutput( }, locations: [location], partialFingerprints: { - primaryLocationLineHash: fingerprintHash(ruleId, finding.pkg.name, finding.pkg.version), + primaryLocationLineHash: sarifFingerprintHash(`${ruleId}:${finding.pkg.name}@${finding.pkg.version}`), }, }); } } - const extensions = overrideFindings && overrideFindings.length > 0 - ? [buildOverrideSarifComponent()] - : undefined; - + const hasOverrides = overrideFindings != null && overrideFindings.length > 0; + const extensions = hasOverrides ? [buildOverrideSarifComponent()] : undefined; const allResults = [ ...results, - ...(overrideFindings ? (buildOverrideSarifResults(overrideFindings) as SarifResult[]) : []), + ...(hasOverrides ? (buildOverrideSarifResults(overrideFindings!) as SarifResult[]) : []), ]; return { diff --git a/src/utils/sarif.ts b/src/utils/sarif.ts new file mode 100644 index 00000000..2b18559d --- /dev/null +++ b/src/utils/sarif.ts @@ -0,0 +1,5 @@ +import { createHash } from "node:crypto"; + +export function sarifFingerprintHash(key: string): string { + return createHash("sha256").update(key).digest("hex").slice(0, 16); +} diff --git a/tests/output/override-findings-sarif.test.ts b/tests/output/override-findings-sarif.test.ts index 2501f07d..0ab9a0f3 100644 --- a/tests/output/override-findings-sarif.test.ts +++ b/tests/output/override-findings-sarif.test.ts @@ -44,7 +44,7 @@ describe("override-findings-sarif", () => { }); }); -describe("override-findings-sarif — SARIF 2.1.0 result fields", () => { +describe("override-findings-sarif: SARIF 2.1.0 result fields", () => { const finding: OverrideFinding = { ruleId: "OA001", severity: "high", diff --git a/tests/sarif.test.ts b/tests/sarif.test.ts index 0842e871..adf6f1db 100644 --- a/tests/sarif.test.ts +++ b/tests/sarif.test.ts @@ -158,7 +158,7 @@ describe("buildSarifOutput — graceful degradation", () => { }); }); -describe("buildSarifOutput — SARIF 2.1.0 result fields", () => { +describe("buildSarifOutput: SARIF 2.1.0 result fields", () => { let sarif: ReturnType; beforeAll(() => {