Skip to content

Commit e694fcc

Browse files
authored
Merge pull request #220 from OWASP/feature/issue-219-html-report
feat: add --report flag to generate HTML vulnerability dashboard
2 parents 9e1b0f8 + f7df5da commit e694fcc

9 files changed

Lines changed: 813 additions & 1 deletion

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,7 @@ reports/
1414

1515
coverage/
1616
AGENTS.md
17-
CLAUDE.md
17+
CLAUDE.md
18+
.superpowers/
19+
docs/superpowers/
20+
cve-report/

src/cli/args.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ export function parseArgs(argv: string[]): { command: CliCommand; options: Parse
5151
if (arg.startsWith("--min-severity=")) { options.minSeverity = arg.slice("--min-severity=".length); continue; }
5252
if (arg === "--usage" || arg === "--usage-hints") { options.usage = true; continue; }
5353
if (arg === "--only-used") { options.onlyUsed = true; options.usage = true; continue; }
54+
if (arg === "--report") {
55+
const next = argv[i + 1];
56+
if (next !== undefined && !next.startsWith("-")) {
57+
options.report = next;
58+
i++;
59+
} else {
60+
options.report = true;
61+
}
62+
continue;
63+
}
64+
if (arg.startsWith("--report=")) { options.report = arg.slice("--report=".length); continue; }
65+
if (arg === "--no-open") { options.noOpen = true; continue; }
5466
if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
5567
if (!projectArg) { projectArg = arg; continue; }
5668
throw new Error(`Unexpected argument: ${arg}`);

src/cli/help.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export function printHelp(): void {
3535
"",
3636
"Scan options:",
3737
" --json Print JSON output",
38+
" --report [dir] Generate an HTML report in [dir] (default: ./cve-report)",
39+
" --no-open Don't auto-open the report in the browser",
3840
" --fix Apply validated direct dependency fixes and rescan",
3941
" --osv-url <url> Use a custom OSV-compatible advisory endpoint",
4042
" --verbose Show detailed output with fix plan, paths, and full table",

src/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
serializeFinding,
2727
sortFindingsForOutput
2828
} from "./output/formatters.js";
29+
import { buildReportData, writeHtmlReport } from "./output/html-reporter.js";
2930
import {
3031
printSummary,
3132
printActionSummary,
@@ -118,6 +119,10 @@ if (parsedArgs) {
118119
throw new Error("--fix cannot be used with --json");
119120
}
120121

122+
if (options.report && options.json) {
123+
throw new Error("--report cannot be used with --json");
124+
}
125+
121126
let advisorySourceLine: string;
122127
let advisoryDbFreshnessLine: string | null = null;
123128
let advisoryDbWarning: string | null = null;
@@ -263,6 +268,29 @@ if (parsedArgs) {
263268
printCompactOutput(scanState.sorted, scanInput);
264269
}
265270

271+
if (options.report) {
272+
const outputDir = path.resolve(
273+
typeof options.report === "string" ? options.report : "./cve-report"
274+
);
275+
const reportData = buildReportData({
276+
projectPath,
277+
cliVersion,
278+
packageManager: scanInput.source,
279+
lockfileSource: scanInput.filePath ? path.basename(scanInput.filePath) : scanInput.source,
280+
packageCount: packages.length,
281+
findings: scanState.sorted,
282+
suggestedFixCommands: scanState.suggestedFixCommands,
283+
notes: [...scanInput.notes, ...scanState.coverage],
284+
warnings: scanInput.warnings,
285+
});
286+
const { reportPath } = await writeHtmlReport({
287+
outputDir,
288+
data: reportData,
289+
autoOpen: !options.noOpen,
290+
});
291+
console.log(`${chalk.gray("Report:")} ${chalk.cyan(reportPath)}`);
292+
}
293+
266294
const failLevel = normalizeSeverity(options.failOn);
267295
const shouldFail = scanState.sorted.some(f => severityOrder[f.severity] >= severityOrder[failLevel]);
268296
process.exit(shouldFail ? 1 : 0);

src/output/html-reporter.ts

Lines changed: 478 additions & 0 deletions
Large diffs are not rendered by default.

src/output/logo-base64.ts

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,6 @@ export type ParsedOptions = {
119119
output?: string;
120120
usage?: boolean;
121121
onlyUsed?: boolean;
122+
report?: string | true;
123+
noOpen?: boolean;
122124
};

tests/cli-integration.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const printFinalStatusMock = jest.fn<any>();
3131
const printCompactOutputMock = jest.fn<any>();
3232
const buildSuggestedFixCommandPlanMock = jest.fn<any>();
3333
const spawnMock = jest.fn<any>();
34+
const buildReportDataMock = jest.fn<any>();
35+
const writeHtmlReportMock = jest.fn<any>();
3436

3537
jest.unstable_mockModule("../src/cli/help.js", () => ({
3638
printBanner: printBannerMock,
@@ -104,6 +106,11 @@ jest.unstable_mockModule("node:child_process", () => ({
104106
spawn: spawnMock,
105107
}));
106108

109+
jest.unstable_mockModule("../src/output/html-reporter.js", () => ({
110+
buildReportData: buildReportDataMock,
111+
writeHtmlReport: writeHtmlReportMock,
112+
}));
113+
107114
function createScanInput(overrides?: Partial<ScanInput>): ScanInput {
108115
return {
109116
mode: "manifest-fallback",
@@ -202,6 +209,8 @@ describe("CLI integration", () => {
202209
package: finding.pkg.name,
203210
severity: finding.severity,
204211
}));
212+
buildReportDataMock.mockReturnValue({ cliVersion: "1.8.0", findings: [] });
213+
writeHtmlReportMock.mockResolvedValue({ reportPath: "/tmp/cve-report/index.html" });
205214
});
206215

207216
it("returns a json payload and exits successfully when no findings are present", async () => {
@@ -549,4 +558,81 @@ describe("CLI integration", () => {
549558
expect(result.exitCode).toBe(1);
550559
expect(result.stderr.join("\n")).toContain("--fix cannot be used with --json");
551560
});
561+
562+
describe("--report flag", () => {
563+
it("calls writeHtmlReport and prints the report path", async () => {
564+
const packages = [
565+
{ name: "lodash", version: "4.17.21", ecosystem: "npm", paths: [["project", "lodash"]] },
566+
];
567+
loadPackagesMock.mockReturnValue(createScanInput({ packages }));
568+
scanPackagesMock.mockResolvedValue([]);
569+
parseArgsMock.mockReturnValue({
570+
command: "scan",
571+
options: {
572+
failOn: "critical",
573+
batchSize: "100",
574+
searchDepth: "4",
575+
minSeverity: "medium",
576+
report: "./my-report",
577+
noOpen: true,
578+
},
579+
projectArg: ".",
580+
});
581+
582+
const result = await runIndexModule();
583+
584+
expect(writeHtmlReportMock).toHaveBeenCalledWith(
585+
expect.objectContaining({
586+
outputDir: expect.stringContaining("my-report"),
587+
autoOpen: false,
588+
})
589+
);
590+
const output = result.stdout.join("\n");
591+
expect(output).toContain("/tmp/cve-report/index.html");
592+
});
593+
594+
it("throws when --report and --json are both set", async () => {
595+
parseArgsMock.mockReturnValue({
596+
command: "scan",
597+
options: {
598+
failOn: "critical",
599+
batchSize: "100",
600+
searchDepth: "4",
601+
minSeverity: "medium",
602+
report: true,
603+
json: true,
604+
},
605+
projectArg: ".",
606+
});
607+
608+
const result = await runIndexModule();
609+
610+
expect(result.stderr.join("\n")).toContain("--report cannot be used with --json");
611+
});
612+
613+
it("uses ./cve-report as default output dir when --report is true (boolean)", async () => {
614+
const packages = [
615+
{ name: "lodash", version: "4.17.21", ecosystem: "npm", paths: [["project", "lodash"]] },
616+
];
617+
loadPackagesMock.mockReturnValue(createScanInput({ packages }));
618+
scanPackagesMock.mockResolvedValue([]);
619+
parseArgsMock.mockReturnValue({
620+
command: "scan",
621+
options: {
622+
failOn: "critical",
623+
batchSize: "100",
624+
searchDepth: "4",
625+
minSeverity: "medium",
626+
report: true,
627+
noOpen: true,
628+
},
629+
projectArg: ".",
630+
});
631+
632+
await runIndexModule();
633+
634+
const callArgs = writeHtmlReportMock.mock.calls[0][0];
635+
expect(callArgs.outputDir).toContain("cve-report");
636+
});
637+
});
552638
});

0 commit comments

Comments
 (0)