Skip to content
Merged
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
1 change: 1 addition & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> Use a custom OSV-compatible advisory endpoint",
Expand Down
83 changes: 46 additions & 37 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
169 changes: 169 additions & 0 deletions src/output/sarif.ts
Original file line number Diff line number Diff line change
@@ -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<string, never>;

type SarifFix = {
description: { text: string };
artifactChanges: SarifArtifactChange[];
};

type SarifArtifact = {
location: { uri: string; uriBaseId: string };
};

const SARIF_RULE_NAME = "VulnerableDependency";

const LOCKFILE_NAMES: Record<ScanSource, string> = {
"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<string, SarifRule>();
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;
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,5 @@ export type ParsedOptions = {
report?: string | true;
noOpen?: boolean;
noCache?: boolean;
sarif?: boolean;
};
7 changes: 7 additions & 0 deletions src/utils/severity.ts
Original file line number Diff line number Diff line change
@@ -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";
}
7 changes: 7 additions & 0 deletions tests/cli-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const spawnMock = jest.fn<any>();
const buildReportDataMock = jest.fn<any>();
const writeHtmlReportMock = jest.fn<any>();
const installSkillMock = jest.fn<any>();
const writeSarifReportMock = jest.fn<any>(() => "cve-lite-scan-test.sarif");
const deriveLockfileUriMock = jest.fn<any>(() => "package-lock.json");

jest.unstable_mockModule("../src/cli/help.js", () => ({
printBanner: printBannerMock,
Expand Down Expand Up @@ -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>): ScanInput {
return {
mode: "manifest-fallback",
Expand Down
1 change: 1 addition & 0 deletions website/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[=<path>]` | 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`<br/>`cve-lite . --report ./reports` |
| `--no-open` | off | Generate the HTML report without opening it in the browser | `cve-lite . --report --no-open` |

Expand Down
Loading