Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 21 additions & 4 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,21 +166,38 @@ 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 <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:

```bash
pnpm deepsec metrics
```

(Each of these commands accepts `--project-id <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

Expand Down
11 changes: 11 additions & 0 deletions e2e/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
153 changes: 153 additions & 0 deletions packages/deepsec/src/__tests__/sarif.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
11 changes: 7 additions & 4 deletions packages/deepsec/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,12 @@ program

program
.command("export")
.description("Export findings as JSON or as a directory of per-finding markdown files")
.option("--format <kind>", "Output format: json (default) or md-dir", "json")
.option("--project-id <csv>", "Comma-separated project IDs (omit for all)")
.description("Export findings as JSON, SARIF, or per-finding markdown files")
.option("--format <kind>", "Output format: json (default), sarif, or md-dir", "json")
.option(
"--project-id <csv>",
"Comma-separated project IDs (omit for all; SARIF requires exactly one)",
)
.option(
"--min-severity <sev>",
"Only export findings at this severity or above (CRITICAL, HIGH, MEDIUM, HIGH_BUG, BUG, LOW)",
Expand Down Expand Up @@ -335,7 +338,7 @@ program
)
.option(
"--out <path>",
"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);

Expand Down
26 changes: 21 additions & 5 deletions packages/deepsec/src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Severity, number> = {
CRITICAL: 0,
Expand All @@ -25,7 +27,7 @@ interface OwnerSummary {
recentCommitters: { name: string; email: string; date: string }[];
}

interface ExportedFinding {
export interface ExportedFinding {
title: string;
description: string;
severity: Severity;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 <dir>`);
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 <id> when multiple projects are configured`,
);
}

const minSeverity = opts.minSeverity as Severity | undefined;
Expand Down Expand Up @@ -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);
}
Expand Down
Loading