diff --git a/src/cerberus.test.ts b/src/cerberus.test.ts index 28eef6b..4f70ae6 100644 --- a/src/cerberus.test.ts +++ b/src/cerberus.test.ts @@ -88,6 +88,42 @@ describe("mergeSarif", () => { expect(results).toBe(3); expect(sarif.version).toBe("2.1.0"); }); + + it("keeps only referenced rules and rewrites ruleIndex", () => { + // Scanners embed their whole catalogue; only the matched rules may ship. + const log = { + runs: [{ + tool: { driver: { name: "semgrep", rules: [ + { id: "unused-1", help: { markdown: "x".repeat(5000) } }, + { id: "hit-a", properties: { "security-severity": "9.1" }, help: { markdown: "x".repeat(5000) } }, + { id: "unused-2" }, + { id: "hit-b", helpUri: "https://example.com/b" }, + ] } }, + results: [ + { ruleId: "hit-b", ruleIndex: 3, message: { text: "b" } }, + { ruleId: "hit-a", ruleIndex: 1, message: { text: "a" } }, + { ruleId: "hit-a", ruleIndex: 1, message: { text: "a again" } }, + ], + }], + }; + const { sarif } = mergeSarif([log]); + const run = sarif.runs[0] as { tool: { driver: { rules: Array> } }; results: Array> }; + + expect(run.tool.driver.rules.map((r) => r.id)).toEqual(["hit-b", "hit-a"]); + // The severity source survives; the prose does not. + expect(run.tool.driver.rules[1]!.properties).toEqual({ "security-severity": "9.1" }); + expect(run.tool.driver.rules[1]!.help).toBeUndefined(); + // Indices now point at the pruned array. + expect(run.results.map((r) => r.ruleIndex)).toEqual([0, 1, 1]); + expect(JSON.stringify(sarif)).not.toContain("xxxxx"); + }); + + it("drops a ruleIndex that cannot be resolved rather than mispointing it", () => { + const log = { runs: [{ tool: { driver: { rules: [{ id: "a" }] } }, results: [{ ruleIndex: 7, message: { text: "?" } }] }] }; + const { sarif } = mergeSarif([log]); + const run = sarif.runs[0] as { results: Array> }; + expect(run.results[0]!.ruleIndex).toBeUndefined(); + }); }); describe("evaluateGate", () => { diff --git a/src/merge.ts b/src/merge.ts index da890ee..134ca38 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -1,21 +1,89 @@ /** Merge per-scanner SARIF logs into one 2.1.0 log (runs are concatenated — * the backend normalizes and dedups per run). */ -interface SarifLike { - runs?: unknown[]; +interface SarifRule { + id?: string; + [key: string]: unknown; +} + +interface SarifResult { + ruleId?: string; + ruleIndex?: number; + [key: string]: unknown; +} + +interface SarifRun { + tool?: { driver?: { rules?: SarifRule[]; [key: string]: unknown }; [key: string]: unknown }; + results?: SarifResult[]; + [key: string]: unknown; +} + +/** Rule fields the backend uses (severity, docs) or that stay cheap. Everything + * else — `help`, `fullDescription`, `relationships` — is prose we would ship + * by the megabyte. */ +const KEPT_RULE_FIELDS = ["id", "name", "shortDescription", "helpUri", "properties", "defaultConfiguration"]; + +function slimRule(rule: SarifRule): SarifRule { + const out: SarifRule = {}; + for (const field of KEPT_RULE_FIELDS) { + if (rule[field] !== undefined) out[field] = rule[field]; + } + return out; +} + +/** + * Keep only the rules a run's results actually reference, and rewrite their + * `ruleIndex` to match. + * + * Scanners embed their whole catalogue: a Semgrep or Checkov report carries + * thousands of rule definitions with long help texts, so a scan with a handful + * of findings still weighs tens of megabytes and the upload is rejected. + */ +function pruneRun(run: SarifRun): SarifRun { + const rules = run.tool?.driver?.rules; + if (!Array.isArray(rules) || rules.length === 0) return run; + + const results = Array.isArray(run.results) ? run.results : []; + const kept: SarifRule[] = []; + const newIndexById = new Map(); + + const keep = (rule: SarifRule | undefined): number | undefined => { + if (!rule) return undefined; + const id = typeof rule.id === "string" ? rule.id : undefined; + if (id && newIndexById.has(id)) return newIndexById.get(id); + const index = kept.length; + kept.push(slimRule(rule)); + if (id) newIndexById.set(id, index); + return index; + }; + + const newResults = results.map((result) => { + const byIndex = typeof result.ruleIndex === "number" ? rules[result.ruleIndex] : undefined; + const byId = typeof result.ruleId === "string" ? rules.find((r) => r?.id === result.ruleId) : undefined; + const index = keep(byIndex ?? byId); + // Drop a stale ruleIndex rather than let it point at the wrong rule. + const { ruleIndex: _old, ...rest } = result; + return index === undefined ? rest : { ...rest, ruleIndex: index }; + }); + + return { + ...run, + tool: { ...run.tool, driver: { ...run.tool?.driver, rules: kept } }, + results: newResults, + }; } export function mergeSarif(logs: unknown[]): { sarif: { version: string; $schema: string; runs: unknown[] }; results: number } { const runs: unknown[] = []; let results = 0; for (const log of logs) { - const l = (log ?? {}) as SarifLike; + const l = (log ?? {}) as { runs?: unknown[] }; if (!Array.isArray(l.runs)) continue; - for (const run of l.runs) { - if (!run || typeof run !== "object") continue; - runs.push(run); - const r = (run as { results?: unknown[] }).results; - if (Array.isArray(r)) results += r.length; + for (const runRaw of l.runs) { + if (!runRaw || typeof runRaw !== "object") continue; + const run = runRaw as SarifRun; + runs.push(pruneRun(run)); + if (Array.isArray(run.results)) results += run.results.length; } } return {