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..adb8073b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -166,11 +166,29 @@ 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 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: @@ -178,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/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 new file mode 100644 index 00000000..ff2bd8d0 --- /dev/null +++ b/packages/deepsec/src/__tests__/sarif.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { type ExportedFinding, exportCommand } 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" }, + region: { startLine: 12 }, + }, + }, + ], + relatedLocations: [ + { + id: 1, + physicalLocation: { + artifactLocation: { uri: "src/user%20search.ts" }, + region: { startLine: 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.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({ + 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); + }); + + 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 20ef3976..44199a1f 100755 --- a/packages/deepsec/src/cli.ts +++ b/packages/deepsec/src/cli.ts @@ -297,9 +297,12 @@ 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") - .option("--project-id ", "Comma-separated project IDs (omit for all)") + .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; SARIF requires exactly one)", + ) .option( "--min-severity ", "Only export findings at this severity or above (CRITICAL, HIGH, MEDIUM, HIGH_BUG, BUG, LOW)", @@ -335,7 +338,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..aaa63f2d 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,16 @@ 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"}>`); + } + 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; @@ -534,6 +548,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..bff50d1b --- /dev/null +++ b/packages/deepsec/src/commands/sarif.ts @@ -0,0 +1,194 @@ +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 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, + 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 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), + }, + ...(line !== undefined + ? { + region: { + startLine: line, + }, + } + : {}), + }; +} + +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]!; + 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: [securitySeverity ? "security" : "correctness"], + ...(securitySeverity ? { "security-severity": securitySeverity } : {}), + ...(problemSeverity ? { "problem.severity": problemSeverity } : {}), + "deepsec/severity": representative.severity, + }, + }; + }); + + 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", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "deepsec", + semanticVersion: version, + informationUri: "https://github.com/vercel-labs/deepsec", + rules, + }, + }, + results, + }, + ], + }; +}