From 5f552d24e0e8686151677417cc08e7a0dc371022 Mon Sep 17 00:00:00 2001 From: Jaden Fix <163031992+jadenfix@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:41:30 -0700 Subject: [PATCH 1/3] Add SARIF export support --- README.md | 1 + docs/getting-started.md | 6 +- packages/deepsec/src/__tests__/sarif.test.ts | 115 ++++++++++++++ packages/deepsec/src/cli.ts | 6 +- packages/deepsec/src/commands/export.ts | 21 ++- packages/deepsec/src/commands/sarif.ts | 159 +++++++++++++++++++ 6 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 packages/deepsec/src/__tests__/sarif.test.ts create mode 100644 packages/deepsec/src/commands/sarif.ts diff --git a/README.md b/README.md index 8ab6922d..52c1ea23 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ pnpm deepsec scan pnpm deepsec process pnpm deepsec revalidate # optional, cuts FP rate pnpm deepsec export --format md-dir --out ./findings +pnpm deepsec export --format sarif --out findings.sarif ``` If you feel like the `deepsec` should look at more parts of the code, give it [the writing matchers](docs/writing-matchers.md) doc to find more valuable starting points in your code base. diff --git a/docs/getting-started.md b/docs/getting-started.md index d46203ea..6a99c6a8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -166,11 +166,15 @@ Both optional, but worth running on the HIGH/CRITICAL set. ```bash pnpm deepsec export --format md-dir --out ./findings pnpm deepsec export --format json --out findings.json +pnpm deepsec export --format sarif --out findings.sarif ``` `md-dir` writes one markdown file per finding under `./findings/{CRITICAL,HIGH,MEDIUM,…}/`. `json` writes a single array -suitable for piping to a downstream issue tracker. +suitable for piping to a downstream issue tracker. `sarif` writes a +[SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) +log for code-scanning tools such as GitHub code scanning. SARIF output requires +`--out` and honors the same project, severity, slug, date, and revalidation filters. For a quick aggregate look: diff --git a/packages/deepsec/src/__tests__/sarif.test.ts b/packages/deepsec/src/__tests__/sarif.test.ts new file mode 100644 index 00000000..9f3279e0 --- /dev/null +++ b/packages/deepsec/src/__tests__/sarif.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import type { ExportedFinding } from "../commands/export.js"; +import { toSarif } from "../commands/sarif.js"; + +function finding(overrides: Partial = {}): ExportedFinding { + return { + title: "[HIGH] SQL injection", + description: "Untrusted input reaches a database query.", + severity: "HIGH", + labels: ["security", "slug:sql-injection"], + metadata: { + projectId: "web", + filePath: "src/user search.ts", + lineNumbers: [15, 12, 15], + severity: "HIGH", + vulnSlug: "sql-injection", + confidence: "high", + discoveredAt: "2026-07-18T12:00:00.000Z", + runId: "run-1", + owners: { + teams: [], + oncall: [], + managers: [], + contributors: [], + recentCommitters: [], + }, + }, + ...overrides, + }; +} + +describe("toSarif", () => { + it("emits SARIF 2.1.0 rules, results, and source locations", () => { + const sarif = toSarif([finding()], "2.2.3"); + const run = sarif.runs[0]; + const rule = run.tool.driver.rules[0]; + const result = run.results[0]; + + expect(sarif.$schema).toBe("https://json.schemastore.org/sarif-2.1.0.json"); + expect(sarif.version).toBe("2.1.0"); + expect(run.tool.driver).toMatchObject({ name: "deepsec", semanticVersion: "2.2.3" }); + expect(rule).toMatchObject({ + id: "sql-injection", + shortDescription: { text: "SQL injection" }, + defaultConfiguration: { level: "error" }, + }); + expect(result).toMatchObject({ + ruleId: "sql-injection", + ruleIndex: 0, + level: "error", + message: { text: "SQL injection" }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: "src/user%20search.ts", uriBaseId: "%SRCROOT%" }, + region: { startLine: 12, endLine: 15 }, + }, + }, + ], + }); + }); + + it("deduplicates and sorts rules while preserving result rule indexes", () => { + const commandInjection = finding({ + title: "[LOW] Command injection", + severity: "LOW", + metadata: { + ...finding().metadata, + vulnSlug: "command-injection", + severity: "LOW", + }, + }); + const sarif = toSarif([finding(), commandInjection, finding()], "2.2.3"); + const run = sarif.runs[0]; + + expect(run.tool.driver.rules.map((rule) => rule.id)).toEqual([ + "command-injection", + "sql-injection", + ]); + expect(run.results.map((result) => result.ruleIndex)).toEqual([1, 0, 1]); + }); + + it.each([ + ["CRITICAL", "error"], + ["HIGH", "error"], + ["HIGH_BUG", "warning"], + ["MEDIUM", "warning"], + ["BUG", "warning"], + ["LOW", "note"], + ] as const)("maps %s findings to the %s SARIF level", (severity, level) => { + const input = finding({ + severity, + metadata: { ...finding().metadata, severity }, + }); + expect(toSarif([input], "2.2.3").runs[0].results[0].level).toBe(level); + }); + + it("produces deterministic fingerprints from finding identity", () => { + const original = toSarif([finding()], "2.2.3").runs[0].results[0].partialFingerprints; + const reorderedLines = finding({ + metadata: { ...finding().metadata, lineNumbers: [12, 15] }, + }); + const reordered = toSarif([reorderedLines], "2.2.3").runs[0].results[0].partialFingerprints; + const moved = finding({ metadata: { ...finding().metadata, lineNumbers: [16] } }); + const movedFingerprint = toSarif([moved], "2.2.3").runs[0].results[0].partialFingerprints; + const differentTitle = finding({ title: "[HIGH] Second SQL injection" }); + const differentTitleFingerprint = toSarif([differentTitle], "2.2.3").runs[0].results[0] + .partialFingerprints; + + expect(original).toEqual(reordered); + expect(original["deepsecFindingId/v1"]).toMatch(/^[a-f0-9]{64}$/); + expect(movedFingerprint).toEqual(original); + expect(differentTitleFingerprint).not.toEqual(original); + }); +}); diff --git a/packages/deepsec/src/cli.ts b/packages/deepsec/src/cli.ts index 20ef3976..c1131d8b 100755 --- a/packages/deepsec/src/cli.ts +++ b/packages/deepsec/src/cli.ts @@ -297,8 +297,8 @@ program program .command("export") - .description("Export findings as JSON or as a directory of per-finding markdown files") - .option("--format ", "Output format: json (default) or md-dir", "json") + .description("Export findings as JSON, SARIF, or per-finding markdown files") + .option("--format ", "Output format: json (default), sarif, or md-dir", "json") .option("--project-id ", "Comma-separated project IDs (omit for all)") .option( "--min-severity ", @@ -335,7 +335,7 @@ program ) .option( "--out ", - "Output path. JSON format: file (default: stdout). md-dir format: directory (required).", + "Output path. JSON: file (default: stdout). SARIF: file (required). md-dir: directory (required).", ) .action(exportCommand); diff --git a/packages/deepsec/src/commands/export.ts b/packages/deepsec/src/commands/export.ts index 3ed50a7f..c46b1678 100644 --- a/packages/deepsec/src/commands/export.ts +++ b/packages/deepsec/src/commands/export.ts @@ -5,6 +5,8 @@ import type { FileRecord, Finding, Severity } from "@deepsec/core"; import { dataDir, getDataRoot, loadAllFileRecords } from "@deepsec/core"; import { BOLD, DIM, GREEN, RESET, YELLOW } from "../formatters.js"; import { resolveAgentType } from "../resolve-agent-type.js"; +import { getDeepsecVersion } from "../version.js"; +import { toSarif } from "./sarif.js"; const SEVERITY_ORDER: Record = { CRITICAL: 0, @@ -25,7 +27,7 @@ interface OwnerSummary { recentCommitters: { name: string; email: string; date: string }[]; } -interface ExportedFinding { +export interface ExportedFinding { title: string; description: string; severity: Severity; @@ -255,6 +257,13 @@ function writeJson(findings: ExportedFinding[], out: string | undefined) { } } +function writeSarif(findings: ExportedFinding[], out: string) { + const sarif = JSON.stringify(toSarif(findings, getDeepsecVersion()), null, 2); + fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true }); + fs.writeFileSync(out, sarif + "\n"); + console.log(`\n${GREEN}Exported ${findings.length} finding(s)${RESET} → ${BOLD}${out}${RESET}`); +} + function writeMdDir(findings: ExportedFinding[], out: string) { const root = path.resolve(out); fs.mkdirSync(root, { recursive: true }); @@ -342,11 +351,11 @@ export async function exportCommand(opts: { : listProjectIds(); const format = opts.format ?? "json"; - if (format !== "json" && format !== "md-dir") { - throw new Error(`--format must be "json" or "md-dir", got "${format}"`); + if (format !== "json" && format !== "md-dir" && format !== "sarif") { + throw new Error(`--format must be "json", "md-dir", or "sarif", got "${format}"`); } - if (format === "md-dir" && !opts.out) { - throw new Error(`--format md-dir requires --out `); + if ((format === "md-dir" || format === "sarif") && !opts.out) { + throw new Error(`--format ${format} requires --out <${format === "md-dir" ? "dir" : "file"}>`); } const minSeverity = opts.minSeverity as Severity | undefined; @@ -534,6 +543,8 @@ export async function exportCommand(opts: { if (format === "md-dir") { writeMdDir(findings, opts.out!); + } else if (format === "sarif") { + writeSarif(findings, opts.out!); } else { writeJson(findings, opts.out); } diff --git a/packages/deepsec/src/commands/sarif.ts b/packages/deepsec/src/commands/sarif.ts new file mode 100644 index 00000000..21a0a396 --- /dev/null +++ b/packages/deepsec/src/commands/sarif.ts @@ -0,0 +1,159 @@ +import crypto from "node:crypto"; + +type Severity = "CRITICAL" | "HIGH" | "HIGH_BUG" | "MEDIUM" | "BUG" | "LOW"; + +interface SarifFinding { + title: string; + description: string; + severity: Severity; + labels: string[]; + assignee?: string; + metadata: { + projectId: string; + filePath: string; + lineNumbers: number[]; + vulnSlug: string; + confidence: string; + discoveredAt: string; + runId: string; + revalidation?: { verdict: string }; + githubUrl?: string; + }; +} + +type SarifLevel = "error" | "warning" | "note"; + +const SARIF_LEVEL: Record = { + CRITICAL: "error", + HIGH: "error", + HIGH_BUG: "warning", + MEDIUM: "warning", + BUG: "warning", + LOW: "note", +}; + +const SEVERITY_ORDER: Record = { + CRITICAL: 0, + HIGH: 1, + HIGH_BUG: 2, + MEDIUM: 3, + BUG: 4, + LOW: 5, +}; + +function titleWithoutSeverity(title: string): string { + return title.replace(/^\[[^\]]+\]\s*/, ""); +} + +function artifactUri(filePath: string): string { + return filePath + .replaceAll("\\", "/") + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/"); +} + +function fingerprint(finding: SarifFinding): string { + return crypto + .createHash("sha256") + .update( + `${finding.metadata.projectId}\0${finding.metadata.filePath.replaceAll("\\", "/")}\0${finding.metadata.vulnSlug}\0${titleWithoutSeverity(finding.title).trim().toLowerCase()}`, + ) + .digest("hex"); +} + +function physicalLocation(finding: SarifFinding) { + const lines = finding.metadata.lineNumbers.filter((line) => Number.isInteger(line) && line > 0); + const startLine = lines.length > 0 ? Math.min(...lines) : undefined; + const endLine = lines.length > 0 ? Math.max(...lines) : undefined; + + return { + artifactLocation: { + uri: artifactUri(finding.metadata.filePath), + uriBaseId: "%SRCROOT%", + }, + ...(startLine !== undefined + ? { + region: { + startLine, + ...(endLine !== startLine ? { endLine } : {}), + }, + } + : {}), + }; +} + +export function toSarif(findings: SarifFinding[], version: string) { + const ruleFindings = new Map(); + for (const finding of findings) { + const existing = ruleFindings.get(finding.metadata.vulnSlug) ?? []; + existing.push(finding); + ruleFindings.set(finding.metadata.vulnSlug, existing); + } + + const ruleIds = [...ruleFindings.keys()].sort(); + const ruleIndexes = new Map(ruleIds.map((ruleId, index) => [ruleId, index])); + const rules = ruleIds.map((ruleId) => { + const matches = ruleFindings.get(ruleId)!; + const representative = [...matches].sort((a, b) => { + const severityDifference = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]; + if (severityDifference !== 0) return severityDifference; + return a.title.localeCompare(b.title); + })[0]!; + + return { + id: ruleId, + shortDescription: { text: titleWithoutSeverity(representative.title) }, + defaultConfiguration: { level: SARIF_LEVEL[representative.severity] }, + properties: { + tags: ["security"], + "deepsec/severity": representative.severity, + }, + }; + }); + + const results = findings.map((finding) => ({ + ruleId: finding.metadata.vulnSlug, + ruleIndex: ruleIndexes.get(finding.metadata.vulnSlug)!, + level: SARIF_LEVEL[finding.severity], + message: { + text: titleWithoutSeverity(finding.title), + markdown: finding.description, + }, + locations: [{ physicalLocation: physicalLocation(finding) }], + partialFingerprints: { + "deepsecFindingId/v1": fingerprint(finding), + }, + properties: { + tags: finding.labels, + "deepsec/projectId": finding.metadata.projectId, + "deepsec/severity": finding.severity, + "deepsec/confidence": finding.metadata.confidence, + "deepsec/discoveredAt": finding.metadata.discoveredAt, + "deepsec/runId": finding.metadata.runId, + ...(finding.assignee ? { "deepsec/assignee": finding.assignee } : {}), + ...(finding.metadata.githubUrl ? { "deepsec/githubUrl": finding.metadata.githubUrl } : {}), + ...(finding.metadata.revalidation + ? { "deepsec/revalidationVerdict": finding.metadata.revalidation.verdict } + : {}), + }, + })); + + return { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "deepsec", + semanticVersion: version, + informationUri: "https://github.com/vercel-labs/deepsec", + rules, + }, + }, + results, + }, + ], + }; +} From 3f987d635bc3430289b69f3d283e223816a47316 Mon Sep 17 00:00:00 2001 From: Jaden Fix <163031992+jadenfix@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:06:12 -0700 Subject: [PATCH 2/3] Harden SARIF export integration --- e2e/pipeline.test.ts | 11 +++++++++++ packages/deepsec/src/__tests__/sarif.test.ts | 2 +- packages/deepsec/src/commands/sarif.ts | 1 - 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/e2e/pipeline.test.ts b/e2e/pipeline.test.ts index b980c11e..06510e91 100644 --- a/e2e/pipeline.test.ts +++ b/e2e/pipeline.test.ts @@ -287,6 +287,17 @@ describe("pipeline e2e", () => { expect(exportTp.status).toBe(0); expect(JSON.parse(fs.readFileSync(exportTpPath, "utf-8")).length).toBe(exported.length); + const sarifPath = path.join(workspaceDir, "exported.sarif"); + const exportSarif = runBundle( + ["export", "--format", "sarif", "--out", sarifPath], + workspaceDir, + ); + expect(exportSarif.status, `export-sarif stderr: ${exportSarif.stderr}`).toBe(0); + const sarif = JSON.parse(fs.readFileSync(sarifPath, "utf-8")); + expect(sarif.version).toBe("2.1.0"); + expect(sarif.runs[0].tool.driver.name).toBe("deepsec"); + expect(sarif.runs[0].results).toHaveLength(exported.length); + // export --format md-dir — directory of one .md per finding. const mdDir = path.join(workspaceDir, "exported"); const exportMd = runBundle(["export", "--format", "md-dir", "--out", mdDir], workspaceDir); diff --git a/packages/deepsec/src/__tests__/sarif.test.ts b/packages/deepsec/src/__tests__/sarif.test.ts index 9f3279e0..9806fac8 100644 --- a/packages/deepsec/src/__tests__/sarif.test.ts +++ b/packages/deepsec/src/__tests__/sarif.test.ts @@ -52,7 +52,7 @@ describe("toSarif", () => { locations: [ { physicalLocation: { - artifactLocation: { uri: "src/user%20search.ts", uriBaseId: "%SRCROOT%" }, + artifactLocation: { uri: "src/user%20search.ts" }, region: { startLine: 12, endLine: 15 }, }, }, diff --git a/packages/deepsec/src/commands/sarif.ts b/packages/deepsec/src/commands/sarif.ts index 21a0a396..2487ba56 100644 --- a/packages/deepsec/src/commands/sarif.ts +++ b/packages/deepsec/src/commands/sarif.ts @@ -70,7 +70,6 @@ function physicalLocation(finding: SarifFinding) { return { artifactLocation: { uri: artifactUri(finding.metadata.filePath), - uriBaseId: "%SRCROOT%", }, ...(startLine !== undefined ? { From ceacf517f5b96015fa84a52e1623d99947423532 Mon Sep 17 00:00:00 2001 From: Jaden Fix <163031992+jadenfix@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:58:45 -0700 Subject: [PATCH 3/3] Harden SARIF code scanning output --- docs/getting-started.md | 21 +++- packages/deepsec/src/__tests__/sarif.test.ts | 42 +++++++- packages/deepsec/src/cli.ts | 5 +- packages/deepsec/src/commands/export.ts | 5 + packages/deepsec/src/commands/sarif.ts | 102 +++++++++++++------ 5 files changed, 135 insertions(+), 40 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 6a99c6a8..adb8073b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -174,7 +174,21 @@ pnpm deepsec export --format sarif --out findings.sarif suitable for piping to a downstream issue tracker. `sarif` writes a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log for code-scanning tools such as GitHub code scanning. SARIF output requires -`--out` and honors the same project, severity, slug, date, and revalidation filters. +`--out` and exactly one project, and honors the same severity, slug, date, and +revalidation filters. Pass `--project-id ` when your config has multiple projects. + +To upload the file to GitHub code scanning, run the official action after DeepSec +exports the findings: + +```yaml +- uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: findings.sarif +``` + +The workflow needs `security-events: write` permission. The upload action also adds +GitHub's source-based `primaryLocationLineHash`; DeepSec keeps its own stable finding +identity in the SARIF file for other consumers. For a quick aggregate look: @@ -182,9 +196,8 @@ For a quick aggregate look: pnpm deepsec metrics ``` -(Each of these commands accepts `--project-id ` if your config has -multiple projects; the auto-resolution only kicks in when there's -exactly one.) +(`md-dir` and `json` accept comma-separated project IDs. SARIF accepts one project +per file so repository-relative source locations remain unambiguous.) ## Next diff --git a/packages/deepsec/src/__tests__/sarif.test.ts b/packages/deepsec/src/__tests__/sarif.test.ts index 9806fac8..ff2bd8d0 100644 --- a/packages/deepsec/src/__tests__/sarif.test.ts +++ b/packages/deepsec/src/__tests__/sarif.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { ExportedFinding } from "../commands/export.js"; +import { type ExportedFinding, exportCommand } from "../commands/export.js"; import { toSarif } from "../commands/sarif.js"; function finding(overrides: Partial = {}): ExportedFinding { @@ -53,7 +53,16 @@ describe("toSarif", () => { { physicalLocation: { artifactLocation: { uri: "src/user%20search.ts" }, - region: { startLine: 12, endLine: 15 }, + region: { startLine: 12 }, + }, + }, + ], + relatedLocations: [ + { + id: 1, + physicalLocation: { + artifactLocation: { uri: "src/user%20search.ts" }, + region: { startLine: 15 }, }, }, ], @@ -95,6 +104,25 @@ describe("toSarif", () => { expect(toSarif([input], "2.2.3").runs[0].results[0].level).toBe(level); }); + it.each([ + ["CRITICAL", { tags: ["security"], "security-severity": "9.5" }], + ["HIGH", { tags: ["security"], "security-severity": "8.0" }], + ["MEDIUM", { tags: ["security"], "security-severity": "5.0" }], + ["LOW", { tags: ["security"], "security-severity": "2.0" }], + ["HIGH_BUG", { tags: ["correctness"], "problem.severity": "error" }], + ["BUG", { tags: ["correctness"], "problem.severity": "warning" }], + ] as const)("maps %s findings to GitHub rule metadata", (severity, properties) => { + const input = finding({ + severity, + metadata: { ...finding().metadata, severity }, + }); + + const run = toSarif([input], "2.2.3").runs[0]; + + expect(run.tool.driver.rules[0].properties).toMatchObject(properties); + expect(run.results[0].properties.tags[0]).toBe(properties.tags[0]); + }); + it("produces deterministic fingerprints from finding identity", () => { const original = toSarif([finding()], "2.2.3").runs[0].results[0].partialFingerprints; const reorderedLines = finding({ @@ -112,4 +140,14 @@ describe("toSarif", () => { expect(movedFingerprint).toEqual(original); expect(differentTitleFingerprint).not.toEqual(original); }); + + it("rejects ambiguous multi-project SARIF exports", async () => { + await expect( + exportCommand({ + format: "sarif", + out: "findings.sarif", + projectId: "web,api", + }), + ).rejects.toThrow("--format sarif requires exactly one project"); + }); }); diff --git a/packages/deepsec/src/cli.ts b/packages/deepsec/src/cli.ts index c1131d8b..44199a1f 100755 --- a/packages/deepsec/src/cli.ts +++ b/packages/deepsec/src/cli.ts @@ -299,7 +299,10 @@ program .command("export") .description("Export findings as JSON, SARIF, or per-finding markdown files") .option("--format ", "Output format: json (default), sarif, or md-dir", "json") - .option("--project-id ", "Comma-separated project IDs (omit for all)") + .option( + "--project-id ", + "Comma-separated project IDs (omit for all; SARIF requires exactly one)", + ) .option( "--min-severity ", "Only export findings at this severity or above (CRITICAL, HIGH, MEDIUM, HIGH_BUG, BUG, LOW)", diff --git a/packages/deepsec/src/commands/export.ts b/packages/deepsec/src/commands/export.ts index c46b1678..aaa63f2d 100644 --- a/packages/deepsec/src/commands/export.ts +++ b/packages/deepsec/src/commands/export.ts @@ -357,6 +357,11 @@ export async function exportCommand(opts: { if ((format === "md-dir" || format === "sarif") && !opts.out) { throw new Error(`--format ${format} requires --out <${format === "md-dir" ? "dir" : "file"}>`); } + if (format === "sarif" && projectIds.length !== 1) { + throw new Error( + `--format sarif requires exactly one project; pass --project-id when multiple projects are configured`, + ); + } const minSeverity = opts.minSeverity as Severity | undefined; const onlySeverity = opts.onlySeverity as Severity | undefined; diff --git a/packages/deepsec/src/commands/sarif.ts b/packages/deepsec/src/commands/sarif.ts index 2487ba56..bff50d1b 100644 --- a/packages/deepsec/src/commands/sarif.ts +++ b/packages/deepsec/src/commands/sarif.ts @@ -32,6 +32,18 @@ const SARIF_LEVEL: Record = { LOW: "note", }; +const SECURITY_SEVERITY: Partial> = { + CRITICAL: "9.5", + HIGH: "8.0", + MEDIUM: "5.0", + LOW: "2.0", +}; + +const PROBLEM_SEVERITY: Partial> = { + HIGH_BUG: "error", + BUG: "warning", +}; + const SEVERITY_ORDER: Record = { CRITICAL: 0, HIGH: 1, @@ -62,20 +74,29 @@ function fingerprint(finding: SarifFinding): string { .digest("hex"); } -function physicalLocation(finding: SarifFinding) { - const lines = finding.metadata.lineNumbers.filter((line) => Number.isInteger(line) && line > 0); - const startLine = lines.length > 0 ? Math.min(...lines) : undefined; - const endLine = lines.length > 0 ? Math.max(...lines) : undefined; +function resultTags(finding: SarifFinding): string[] { + const classification = SECURITY_SEVERITY[finding.severity] ? "security" : "correctness"; + return [ + classification, + ...finding.labels.filter((tag) => tag !== "security" && tag !== "correctness"), + ]; +} + +function sourceLines(finding: SarifFinding): number[] { + return [ + ...new Set(finding.metadata.lineNumbers.filter((line) => Number.isInteger(line) && line > 0)), + ].sort((a, b) => a - b); +} +function physicalLocation(finding: SarifFinding, line?: number) { return { artifactLocation: { uri: artifactUri(finding.metadata.filePath), }, - ...(startLine !== undefined + ...(line !== undefined ? { region: { - startLine, - ...(endLine !== startLine ? { endLine } : {}), + startLine: line, }, } : {}), @@ -99,44 +120,59 @@ export function toSarif(findings: SarifFinding[], version: string) { if (severityDifference !== 0) return severityDifference; return a.title.localeCompare(b.title); })[0]!; + const securitySeverity = SECURITY_SEVERITY[representative.severity]; + const problemSeverity = PROBLEM_SEVERITY[representative.severity]; return { id: ruleId, shortDescription: { text: titleWithoutSeverity(representative.title) }, defaultConfiguration: { level: SARIF_LEVEL[representative.severity] }, properties: { - tags: ["security"], + tags: [securitySeverity ? "security" : "correctness"], + ...(securitySeverity ? { "security-severity": securitySeverity } : {}), + ...(problemSeverity ? { "problem.severity": problemSeverity } : {}), "deepsec/severity": representative.severity, }, }; }); - const results = findings.map((finding) => ({ - ruleId: finding.metadata.vulnSlug, - ruleIndex: ruleIndexes.get(finding.metadata.vulnSlug)!, - level: SARIF_LEVEL[finding.severity], - message: { - text: titleWithoutSeverity(finding.title), - markdown: finding.description, - }, - locations: [{ physicalLocation: physicalLocation(finding) }], - partialFingerprints: { - "deepsecFindingId/v1": fingerprint(finding), - }, - properties: { - tags: finding.labels, - "deepsec/projectId": finding.metadata.projectId, - "deepsec/severity": finding.severity, - "deepsec/confidence": finding.metadata.confidence, - "deepsec/discoveredAt": finding.metadata.discoveredAt, - "deepsec/runId": finding.metadata.runId, - ...(finding.assignee ? { "deepsec/assignee": finding.assignee } : {}), - ...(finding.metadata.githubUrl ? { "deepsec/githubUrl": finding.metadata.githubUrl } : {}), - ...(finding.metadata.revalidation - ? { "deepsec/revalidationVerdict": finding.metadata.revalidation.verdict } + const results = findings.map((finding) => { + const lines = sourceLines(finding); + return { + ruleId: finding.metadata.vulnSlug, + ruleIndex: ruleIndexes.get(finding.metadata.vulnSlug)!, + level: SARIF_LEVEL[finding.severity], + message: { + text: titleWithoutSeverity(finding.title), + markdown: finding.description, + }, + locations: [{ physicalLocation: physicalLocation(finding, lines[0]) }], + ...(lines.length > 1 + ? { + relatedLocations: lines.slice(1).map((line, index) => ({ + id: index + 1, + physicalLocation: physicalLocation(finding, line), + })), + } : {}), - }, - })); + partialFingerprints: { + "deepsecFindingId/v1": fingerprint(finding), + }, + properties: { + tags: resultTags(finding), + "deepsec/projectId": finding.metadata.projectId, + "deepsec/severity": finding.severity, + "deepsec/confidence": finding.metadata.confidence, + "deepsec/discoveredAt": finding.metadata.discoveredAt, + "deepsec/runId": finding.metadata.runId, + ...(finding.assignee ? { "deepsec/assignee": finding.assignee } : {}), + ...(finding.metadata.githubUrl ? { "deepsec/githubUrl": finding.metadata.githubUrl } : {}), + ...(finding.metadata.revalidation + ? { "deepsec/revalidationVerdict": finding.metadata.revalidation.verdict } + : {}), + }, + }; + }); return { $schema: "https://json.schemastore.org/sarif-2.1.0.json",