diff --git a/src/output/override-findings-sarif.ts b/src/output/override-findings-sarif.ts index 49662397..f0a2615b 100644 --- a/src/output/override-findings-sarif.ts +++ b/src/output/override-findings-sarif.ts @@ -1,5 +1,7 @@ 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" }, @@ -15,13 +17,7 @@ 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 SEVERITY_TO_LEVEL: Record = { - critical: "error", - high: "error", - medium: "warning", - low: "note", - info: "note", -}; +const OA_RULE_INDEX = new Map(OA_RULES.map((r, i) => [r.id, i])); export function buildOverrideSarifComponent(): unknown { return { @@ -39,12 +35,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,29 +51,41 @@ export interface OverrideSarifResult { region: { startLine: number }; }; }>; + partialFingerprints: { primaryLocationLineHash: string }; properties?: Record; } export function buildOverrideSarifResults( findings: ReadonlyArray, ): OverrideSarifResult[] { - return findings.map((f) => ({ - ruleId: f.ruleId, - rule: { id: f.ruleId, toolComponent: { index: 0 } }, - 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: severityToSarifLevel(f.severity), + message: { text: f.message }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: f.location.file, uriBaseId: "%SRCROOT%" }, + region: { startLine: f.location.line ?? 1 }, + }, }, + ], + partialFingerprints: { + primaryLocationLineHash: sarifFingerprintHash(`${f.ruleId}:${f.package.name}:${f.location.file}`), }, - ], - 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/src/output/sarif.ts b/src/output/sarif.ts index 4adb8e68..a6b5c7c1 100644 --- a/src/output/sarif.ts +++ b/src/output/sarif.ts @@ -6,8 +6,11 @@ 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"; + type SarifLog = { $schema: string; version: "2.1.0"; @@ -34,15 +37,17 @@ type SarifRule = { fullDescription: { text: string }; help?: { text: string; markdown: string }; helpUri: string; - defaultConfiguration: { level: "error" | "warning" | "note" }; properties: { tags: string[] }; }; type SarifResult = { ruleId: string; + ruleIndex: number; + kind: "open"; level: "error" | "warning" | "note"; message: { text: string }; locations: SarifLocation[]; + partialFingerprints: { primaryLocationLineHash: string }; }; type SarifLocation = { @@ -135,6 +140,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 +160,8 @@ 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; const details = vuln?.details; @@ -167,33 +175,35 @@ 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"] }, }); } 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: 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 { - $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/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 1b455dc6..0ab9a0f3 100644 --- a/tests/output/override-findings-sarif.test.ts +++ b/tests/output/override-findings-sarif.test.ts @@ -43,3 +43,69 @@ 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"); + }); + + 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/); + }); +}); diff --git a/tests/sarif.test.ts b/tests/sarif.test.ts index 01177551..adf6f1db 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" + ); + }); +});