From 2cd0482c53e742726559c27e6cf2df4c204fb662 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Mon, 11 May 2026 21:43:58 -0400 Subject: [PATCH] feat: add --sarif output flag for SARIF 2.1.0 support - New src/output/sarif.ts with buildSarifOutput, writeSarifReport, and deriveLockfileUri; produces one result per CVE per finding with rule deduplication and optional fixes array from runnableFixCommand - New src/utils/severity.ts extracts severityToSarifLevel as a shared utility - --sarif and --json can be combined; --sarif and --report are mutually exclusive - Refactored output block in index.ts so --sarif --json writes both files and still renders terminal output - Adds SARIF guide page, CLI reference row, sidebar entry, and README update Closes #341 --- README.md | 5 +- src/cli/args.ts | 5 + src/cli/help.ts | 1 + src/index.ts | 83 +++++++++-------- src/output/sarif.ts | 169 ++++++++++++++++++++++++++++++++++ src/types.ts | 1 + src/utils/severity.ts | 7 ++ tests/cli-integration.test.ts | 7 ++ website/docs/cli-reference.md | 1 + website/docs/sarif.md | 57 ++++++++++++ website/sidebars.ts | 1 + 11 files changed, 299 insertions(+), 38 deletions(-) create mode 100644 src/output/sarif.ts create mode 100644 src/utils/severity.ts create mode 100644 website/docs/sarif.md diff --git a/README.md b/README.md index 86e9b99..d00c699 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ CVE Lite CLI fits at every stage of the development workflow, not just CI. **Local development** — run a scan before opening a PR. The default output is fast and minimal. `--verbose` adds the full fix plan with dependency paths and prioritized remediation commands. `--report` opens an interactive HTML dashboard. -**CI pipelines** — use `--fail-on high` to gate builds on severity. JSON output (`--json`) integrates with SIEM, dashboards, and custom automation. SARIF output is on the roadmap for direct integration with GitHub Security. +**CI pipelines** — use `--fail-on high` to gate builds on severity. JSON output (`--json`) integrates with SIEM, dashboards, and custom automation. SARIF output (`--sarif`) writes a SARIF 2.1.0 file for direct integration with GitHub Code Scanning and other SARIF-compatible tools. **Restricted and enterprise environments** — sync the advisory database ahead of time with `cve-lite advisories sync`, then scan offline with `--offline`. No runtime outbound calls during the scan. Syncing ~217,065 advisory records completes in under 9 seconds. @@ -248,6 +248,9 @@ cve-lite /path/to/project --fail-on high # JSON output cve-lite /path/to/project --json +# SARIF output for GitHub Code Scanning and other SARIF-compatible tools +cve-lite /path/to/project --sarif + # Generate an HTML vulnerability dashboard (opens in browser automatically) cve-lite /path/to/project --report cve-lite /path/to/project --report ./my-report --no-open diff --git a/src/cli/args.ts b/src/cli/args.ts index 28a09d7..3a340e6 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -74,10 +74,15 @@ export function parseArgs(argv: string[]): { command: CliCommand; options: Parse if (arg.startsWith("--report=")) { options.report = arg.slice("--report=".length); continue; } if (arg === "--no-open") { options.noOpen = true; continue; } if (arg === "--no-cache") { options.noCache = true; continue; } + if (arg === "--sarif") { options.sarif = true; continue; } if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`); if (!projectArg) { projectArg = arg; continue; } throw new Error(`Unexpected argument: ${arg}`); } + if (options.sarif && options.report) { + throw new Error("cannot combine --sarif and --report"); + } + return { command: "scan", options, projectArg }; } diff --git a/src/cli/help.ts b/src/cli/help.ts index 770081d..7b644ee 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -37,6 +37,7 @@ export function printHelp(): void { "Scan options:", " --json Print JSON output", " --report [dir] Generate an HTML report in [dir] (default: ./cve-report)", + " --sarif Write SARIF 2.1.0 output to a timestamped .sarif file", " --no-open Don't auto-open the report in the browser", " --fix Apply validated direct dependency fixes and rescan", " --osv-url Use a custom OSV-compatible advisory endpoint", diff --git a/src/index.ts b/src/index.ts index 1aaa53a..9edfe95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { sortFindingsForOutput } from "./output/formatters.js"; import { buildReportData, writeHtmlReport } from "./output/html-reporter.js"; +import { writeSarifReport, deriveLockfileUri } from "./output/sarif.js"; import { printSummary, printActionSummary, @@ -234,47 +235,55 @@ if (parsedArgs) { findingsAfterFix: scanState.sorted.length, remainingBySeverity: countBySeverity(scanState.sorted), }); - } else if (options.json) { - - const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - const jsonFilename = `cve-lite-scan-${ts}.json`; - const jsonOutputPath = path.join(process.cwd(), jsonFilename); - fs.writeFileSync(jsonOutputPath, JSON.stringify({ - projectPath, - mode: scanInput.mode, - source: scanInput.source, - packageCount: packages.length, - findingCount: scanState.sorted.length, - suggestedFixCommands: scanState.suggestedFixCommands, - notes: [...scanInput.notes, ...scanState.coverage], - warnings: scanInput.warnings, - skippedDependencies: scanInput.skippedDependencies, - findings: scanState.sorted.map(finding => serializeFinding(finding, scanState.suggestedFixCommands)) - }, null, 2)); - console.log(`${chalk.gray("JSON saved to")} ${chalk.cyan(jsonFilename)}`); - } else if (options.verbose) { - // Verbose output - show all details - const offline = !!options.offline || !!options.offlineDb; - printSummary(scanState.sorted, packages.length, scanInput); - printActionSummary(scanState.sorted); - printSuggestedFixCommands(scanState.sorted, scanInput, { offline }); - printSuggestedFixCommandSkips(scanState.sorted, scanInput, { offline }); - if (scanInput.skippedDependencies.length > 0) { - printSkippedDependencies(scanInput.skippedDependencies); + } else { + if (options.json) { + const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const jsonFilename = `cve-lite-scan-${ts}.json`; + const jsonOutputPath = path.join(process.cwd(), jsonFilename); + fs.writeFileSync(jsonOutputPath, JSON.stringify({ + projectPath, + mode: scanInput.mode, + source: scanInput.source, + packageCount: packages.length, + findingCount: scanState.sorted.length, + suggestedFixCommands: scanState.suggestedFixCommands, + notes: [...scanInput.notes, ...scanState.coverage], + warnings: scanInput.warnings, + skippedDependencies: scanInput.skippedDependencies, + findings: scanState.sorted.map(finding => serializeFinding(finding, scanState.suggestedFixCommands)) + }, null, 2)); + console.log(`${chalk.gray("JSON saved to")} ${chalk.cyan(jsonFilename)}`); } - if (scanState.sorted.length > 0) { - if (scanState.tableFindings.length > 0) { - printTable(scanState.tableFindings, options.all ? null : scanState.minSeverity); + + if (!options.json || options.sarif) { + const offline = !!options.offline || !!options.offlineDb; + if (options.verbose) { + printSummary(scanState.sorted, packages.length, scanInput); + printActionSummary(scanState.sorted); + printSuggestedFixCommands(scanState.sorted, scanInput, { offline }); + printSuggestedFixCommandSkips(scanState.sorted, scanInput, { offline }); + if (scanInput.skippedDependencies.length > 0) { + printSkippedDependencies(scanInput.skippedDependencies); + } + if (scanState.sorted.length > 0) { + if (scanState.tableFindings.length > 0) { + printTable(scanState.tableFindings, options.all ? null : scanState.minSeverity); + } else { + logInfo(`No findings met the table threshold of ${scanState.minSeverity}. Re-run with --all to show everything.`, options); + } + } + printCoverage([...scanInput.notes, ...scanState.coverage]); + printFinalStatus(scanState.sorted); } else { - logInfo(`No findings met the table threshold of ${scanState.minSeverity}. Re-run with --all to show everything.`, options); + printCompactOutput(scanState.sorted, scanInput, { offline, all: !!options.all }); } } - printCoverage([...scanInput.notes, ...scanState.coverage]); - printFinalStatus(scanState.sorted); - } else { - // Simplified output - cleaner, focused view - const offline = !!options.offline || !!options.offlineDb; - printCompactOutput(scanState.sorted, scanInput, { offline, all: !!options.all }); + } + + if (options.sarif) { + const lockfileUri = deriveLockfileUri(scanInput); + const sarifFilename = writeSarifReport(scanState.sorted, lockfileUri, scanState.suggestedFixCommands); + console.log(`${chalk.gray("SARIF report written to")} ${chalk.cyan(sarifFilename)}`); } if (options.report) { diff --git a/src/output/sarif.ts b/src/output/sarif.ts new file mode 100644 index 0000000..26c40e7 --- /dev/null +++ b/src/output/sarif.ts @@ -0,0 +1,169 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Finding, ScanSource } from "../types.js"; +import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; +import { findSuggestedCommandForFinding } from "../remediation/fix-commands.js"; +import { getRecommendedAction } from "./formatters.js"; +import { getCliVersion } from "../utils/version-info.js"; +import { severityToSarifLevel } from "../utils/severity.js"; + +type SarifLog = { + $schema: string; + version: "2.1.0"; + runs: SarifRun[]; +}; + +type SarifRun = { + tool: { driver: SarifDriver }; + results: SarifResult[]; + artifacts: SarifArtifact[]; +}; + +type SarifDriver = { + name: string; + version: string; + informationUri: string; + rules: SarifRule[]; +}; + +type SarifRule = { + id: string; + name: string; + shortDescription: { text: string }; + fullDescription: { text: string }; + helpUri: string; + defaultConfiguration: { level: "error" | "warning" | "note" }; + properties: { tags: string[] }; +}; + +type SarifResult = { + ruleId: string; + level: "error" | "warning" | "note"; + message: { text: string }; + locations: SarifLocation[]; + fixes?: SarifFix[]; +}; + +type SarifLocation = { + physicalLocation: { + artifactLocation: { uri: string; uriBaseId: string }; + region: { startLine: number }; + }; +}; + +type SarifArtifactChange = Record; + +type SarifFix = { + description: { text: string }; + artifactChanges: SarifArtifactChange[]; +}; + +type SarifArtifact = { + location: { uri: string; uriBaseId: string }; +}; + +const SARIF_RULE_NAME = "VulnerableDependency"; + +const LOCKFILE_NAMES: Record = { + "package-lock": "package-lock.json", + "pnpm-lock": "pnpm-lock.yaml", + "yarn-lock": "yarn.lock", + "bun-lock": "bun.lockb", + "package-json": "package.json", + "unknown": "lockfile", +}; + +export function deriveLockfileUri(scanInput: { filePath: string | null; source: ScanSource }): string { + if (scanInput.filePath) return path.basename(scanInput.filePath); + return LOCKFILE_NAMES[scanInput.source] ?? "lockfile"; +} + +export function buildSarifOutput( + findings: Finding[], + lockfileUri: string, + version: string, + plan: SuggestedFixCommandPlan | null, +): SarifLog { + const ruleMap = new Map(); + const results: SarifResult[] = []; + + for (const finding of findings) { + const level = severityToSarifLevel(finding.severity); + const action = getRecommendedAction(finding); + const runnableFixCommand = plan ? findSuggestedCommandForFinding(plan, finding) : null; + + const location: SarifLocation = { + physicalLocation: { + artifactLocation: { uri: lockfileUri, uriBaseId: "%SRCROOT%" }, + region: { startLine: 1 }, + }, + }; + + const ruleIds = finding.cveAliases.length > 0 + ? finding.cveAliases + : finding.vulnerabilities.map(v => v.id); + + for (const ruleId of ruleIds) { + if (!ruleMap.has(ruleId)) { + ruleMap.set(ruleId, { + id: ruleId, + name: SARIF_RULE_NAME, + shortDescription: { text: ruleId }, + fullDescription: { text: `Vulnerable dependency: ${ruleId}` }, + helpUri: `https://osv.dev/vulnerability/${ruleId}`, + defaultConfiguration: { level }, + properties: { tags: ["security", "dependency"] }, + }); + } + + const result: SarifResult = { + ruleId, + level, + message: { + text: `${finding.pkg.name}@${finding.pkg.version} is vulnerable (${finding.severity}). ${action}`, + }, + locations: [location], + }; + + if (runnableFixCommand) { + result.fixes = [{ description: { text: runnableFixCommand }, artifactChanges: [] }]; + } + + results.push(result); + } + } + + return { + $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "CVE Lite CLI", + version, + informationUri: "https://owasp.org/cve-lite-cli/", + rules: Array.from(ruleMap.values()), + }, + }, + results, + artifacts: [ + { location: { uri: lockfileUri, uriBaseId: "%SRCROOT%" } }, + ], + }, + ], + }; +} + +export function writeSarifReport( + findings: Finding[], + lockfileUri: string, + plan: SuggestedFixCommandPlan | null, +): string { + const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const filename = `cve-lite-scan-${ts}.sarif`; + const outputPath = path.join(process.cwd(), filename); + const sarif = buildSarifOutput(findings, lockfileUri, getCliVersion(), plan); + fs.writeFileSync(outputPath, JSON.stringify(sarif, null, 2)); + return filename; +} diff --git a/src/types.ts b/src/types.ts index 63cc401..9356045 100644 --- a/src/types.ts +++ b/src/types.ts @@ -176,4 +176,5 @@ export type ParsedOptions = { report?: string | true; noOpen?: boolean; noCache?: boolean; + sarif?: boolean; }; diff --git a/src/utils/severity.ts b/src/utils/severity.ts new file mode 100644 index 0000000..b0746f9 --- /dev/null +++ b/src/utils/severity.ts @@ -0,0 +1,7 @@ +import type { SeverityLabel } from "../types.js"; + +export function severityToSarifLevel(severity: SeverityLabel): "error" | "warning" | "note" { + if (severity === "critical" || severity === "high") return "error"; + if (severity === "medium") return "warning"; + return "note"; +} diff --git a/tests/cli-integration.test.ts b/tests/cli-integration.test.ts index cd3a83f..50875d4 100644 --- a/tests/cli-integration.test.ts +++ b/tests/cli-integration.test.ts @@ -33,6 +33,8 @@ const spawnMock = jest.fn(); const buildReportDataMock = jest.fn(); const writeHtmlReportMock = jest.fn(); const installSkillMock = jest.fn(); +const writeSarifReportMock = jest.fn(() => "cve-lite-scan-test.sarif"); +const deriveLockfileUriMock = jest.fn(() => "package-lock.json"); jest.unstable_mockModule("../src/cli/help.js", () => ({ printBanner: printBannerMock, @@ -111,6 +113,11 @@ jest.unstable_mockModule("../src/skills/install.js", () => ({ installSkill: installSkillMock, })); +jest.unstable_mockModule("../src/output/sarif.js", () => ({ + writeSarifReport: writeSarifReportMock, + deriveLockfileUri: deriveLockfileUriMock, +})); + function createScanInput(overrides?: Partial): ScanInput { return { mode: "manifest-fallback", diff --git a/website/docs/cli-reference.md b/website/docs/cli-reference.md index fd2ec57..39c6a82 100644 --- a/website/docs/cli-reference.md +++ b/website/docs/cli-reference.md @@ -32,6 +32,7 @@ cve-lite install-skill |---|---|---|---| | `--verbose` | off | Full output: severity table, fix plan, findings table, coverage notes | `cve-lite . --verbose` | | `--json` | off | Machine-readable JSON output (suppresses all other output) | `cve-lite . --json` | +| `--sarif` | off | Write SARIF 2.1.0 output to a timestamped `.sarif` file; can be combined with `--json`; cannot be combined with `--report` | `cve-lite . --sarif` | | `--report[=]` | off / `./cve-report` | Generate an HTML report; optional path sets output directory (default `./cve-report`); opens in browser by default; cannot be used with `--json` | `cve-lite . --report`
`cve-lite . --report ./reports` | | `--no-open` | off | Generate the HTML report without opening it in the browser | `cve-lite . --report --no-open` | diff --git a/website/docs/sarif.md b/website/docs/sarif.md new file mode 100644 index 0000000..9169a12 --- /dev/null +++ b/website/docs/sarif.md @@ -0,0 +1,57 @@ +--- +sidebar_label: SARIF Output +--- + +# SARIF Output + +CVE Lite CLI can write scan results as a [SARIF 2.1.0](https://sarifweb.azurewebsites.net/) file — a standard format supported by GitHub Code Scanning, VS Code, Azure DevOps, and other security tooling. + +## Generating SARIF output + +```bash +cve-lite . --sarif +``` + +This writes a timestamped file (`cve-lite-scan-.sarif`) to the current directory and prints the path. Terminal output renders as normal. + +## Combining with `--json` + +`--sarif` and `--json` can be used together. Both files are written in one scan: + +```bash +cve-lite . --sarif --json +``` + +`--sarif` cannot be combined with `--report`. + +## GitHub Code Scanning integration + +Upload the SARIF file to GitHub's Security tab using the official action: + +```yaml +- name: Scan dependencies + run: cve-lite . --sarif + +- name: Upload SARIF to GitHub + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: cve-lite-scan-*.sarif +``` + +Findings appear in the **Security → Code scanning** tab and as PR annotations. + +## What the SARIF file contains + +Each CVE found produces one SARIF result. A package with multiple CVEs produces one result per CVE, allowing per-CVE review and dismissal in GitHub Code Scanning. + +| SARIF field | Value | +|---|---| +| `ruleId` | CVE ID (e.g. `CVE-2021-44228`) | +| `level` | `error` (critical/high), `warning` (medium), `note` (low/unknown) | +| `message` | Package, version, severity, and recommended action | +| `locations` | Lockfile path relative to repo root | +| `fixes` | Exact install command when one is available | + +## `--fail-on` and exit codes + +`--sarif` does not affect exit codes. The `--fail-on` flag continues to control when the process exits with code `1`. diff --git a/website/sidebars.ts b/website/sidebars.ts index cdd7b15..646718e 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -30,6 +30,7 @@ const sidebars: SidebarsConfig = { items: [ 'workflow-integration', 'ai-assistant-integration', + 'sarif', 'caching', 'offline-advisory-db', 'offline-vs-online-results',