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
60 changes: 35 additions & 25 deletions src/output/override-findings-sarif.ts
Original file line number Diff line number Diff line change
@@ -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" },
Expand All @@ -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<string, "error" | "warning" | "note"> = {
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 {
Expand All @@ -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<{
Expand All @@ -53,29 +51,41 @@ export interface OverrideSarifResult {
region: { startLine: number };
};
}>;
partialFingerprints: { primaryLocationLineHash: string };
properties?: Record<string, unknown>;
}

export function buildOverrideSarifResults(
findings: ReadonlyArray<OverrideFinding>,
): 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,
},
};
});
}
26 changes: 18 additions & 8 deletions src/output/sarif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = {
Expand Down Expand Up @@ -135,6 +140,7 @@ export function buildSarifOutput(
overrideFindings?: ReadonlyArray<OverrideFinding>,
): SarifLog {
const ruleMap = new Map<string, SarifRule>();
const ruleIndexMap = new Map<string, number>();
const results: SarifResult[] = [];

for (const finding of findings) {
Expand All @@ -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;
Expand All @@ -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: [
{
Expand Down
5 changes: 5 additions & 0 deletions src/utils/sarif.ts
Original file line number Diff line number Diff line change
@@ -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);
}
66 changes: 66 additions & 0 deletions tests/output/override-findings-sarif.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
52 changes: 52 additions & 0 deletions tests/sarif.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof buildSarifOutput>;

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"
);
});
});