Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
edf4427
chore: add .superpowers/ to .gitignore
sonukapoor Apr 24, 2026
68116c3
docs: add HTML report dashboard design spec
sonukapoor Apr 24, 2026
6e1bfed
docs: add HTML report dashboard implementation plan
sonukapoor Apr 24, 2026
c170117
feat: add --report and --no-open to ParsedOptions and arg parser
sonukapoor Apr 24, 2026
a1e9b9c
feat: add logo base64 constant for HTML report
sonukapoor Apr 24, 2026
48d83fd
feat: add ReportData type and buildReportData()
sonukapoor Apr 24, 2026
b2bac69
feat: implement renderHtmlReport() with full HTML template
sonukapoor Apr 24, 2026
0dc2e70
fix: escape JSON in script tag, fix copy button injection, add none s…
sonukapoor Apr 24, 2026
3600931
feat: implement writeHtmlReport() with file output and browser open
sonukapoor Apr 24, 2026
0a0afa9
fix: clear timeout handle in openInBrowser to prevent resource leak
sonukapoor Apr 24, 2026
65c1f8b
feat: wire --report flag into scan pipeline
sonukapoor Apr 24, 2026
a757cc1
test: assert outputDir in --report writeHtmlReport call
sonukapoor Apr 24, 2026
8bd0cf6
docs: add --report and --no-open to CLI help text
sonukapoor Apr 24, 2026
e598c8c
fix: remove re-sort in renderHtmlReport to keep row indices in sync w…
sonukapoor Apr 24, 2026
25e4cfb
feat: update links to OWASP URLs and add OWASP badge to report header
sonukapoor Apr 24, 2026
92ddd98
fix: restore expand/collapse after filter by using CSS class instead …
sonukapoor Apr 24, 2026
d23424a
fix: replace invented OWASP SVG with text-only attribution per OWASP …
sonukapoor Apr 24, 2026
88a9f94
feat: add collapsible skipped-fixes section to fix plan in HTML report
sonukapoor Apr 24, 2026
2361d59
feat: replace text attribution with official OWASP logo SVG in report…
sonukapoor Apr 24, 2026
b945a5f
fix: guard openInBrowser with isAbsolute check and explicit shell:fal…
sonukapoor Apr 24, 2026
04af168
feat: update .gitignore
sonukapoor Apr 24, 2026
f7df5da
chore: ignore docs/superpowers/ directory
sonukapoor Apr 24, 2026
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ reports/

coverage/
AGENTS.md
CLAUDE.md
CLAUDE.md
.superpowers/
docs/superpowers/
cve-report/
12 changes: 12 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ export function parseArgs(argv: string[]): { command: CliCommand; options: Parse
if (arg.startsWith("--min-severity=")) { options.minSeverity = arg.slice("--min-severity=".length); continue; }
if (arg === "--usage" || arg === "--usage-hints") { options.usage = true; continue; }
if (arg === "--only-used") { options.onlyUsed = true; options.usage = true; continue; }
if (arg === "--report") {
const next = argv[i + 1];
if (next !== undefined && !next.startsWith("-")) {
options.report = next;
i++;
} else {
options.report = true;
}
continue;
}
if (arg.startsWith("--report=")) { options.report = arg.slice("--report=".length); continue; }
if (arg === "--no-open") { options.noOpen = true; continue; }
if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
if (!projectArg) { projectArg = arg; continue; }
throw new Error(`Unexpected argument: ${arg}`);
Expand Down
2 changes: 2 additions & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export function printHelp(): void {
"",
"Scan options:",
" --json Print JSON output",
" --report [dir] Generate an HTML report in [dir] (default: ./cve-report)",
" --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",
" --verbose Show detailed output with fix plan, paths, and full table",
Expand Down
28 changes: 28 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
serializeFinding,
sortFindingsForOutput
} from "./output/formatters.js";
import { buildReportData, writeHtmlReport } from "./output/html-reporter.js";
import {
printSummary,
printActionSummary,
Expand Down Expand Up @@ -118,6 +119,10 @@ if (parsedArgs) {
throw new Error("--fix cannot be used with --json");
}

if (options.report && options.json) {
throw new Error("--report cannot be used with --json");
}

let advisorySourceLine: string;
let advisoryDbFreshnessLine: string | null = null;
let advisoryDbWarning: string | null = null;
Expand Down Expand Up @@ -263,6 +268,29 @@ if (parsedArgs) {
printCompactOutput(scanState.sorted, scanInput);
}

if (options.report) {
const outputDir = path.resolve(
typeof options.report === "string" ? options.report : "./cve-report"
);
const reportData = buildReportData({
projectPath,
cliVersion,
packageManager: scanInput.source,
lockfileSource: scanInput.filePath ? path.basename(scanInput.filePath) : scanInput.source,
packageCount: packages.length,
findings: scanState.sorted,
suggestedFixCommands: scanState.suggestedFixCommands,
notes: [...scanInput.notes, ...scanState.coverage],
warnings: scanInput.warnings,
});
const { reportPath } = await writeHtmlReport({
outputDir,
data: reportData,
autoOpen: !options.noOpen,
});
console.log(`${chalk.gray("Report:")} ${chalk.cyan(reportPath)}`);
}

const failLevel = normalizeSeverity(options.failOn);
const shouldFail = scanState.sorted.some(f => severityOrder[f.severity] >= severityOrder[failLevel]);
process.exit(shouldFail ? 1 : 0);
Expand Down
478 changes: 478 additions & 0 deletions src/output/html-reporter.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/output/logo-base64.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,6 @@ export type ParsedOptions = {
output?: string;
usage?: boolean;
onlyUsed?: boolean;
report?: string | true;
noOpen?: boolean;
};
86 changes: 86 additions & 0 deletions tests/cli-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const printFinalStatusMock = jest.fn<any>();
const printCompactOutputMock = jest.fn<any>();
const buildSuggestedFixCommandPlanMock = jest.fn<any>();
const spawnMock = jest.fn<any>();
const buildReportDataMock = jest.fn<any>();
const writeHtmlReportMock = jest.fn<any>();

jest.unstable_mockModule("../src/cli/help.js", () => ({
printBanner: printBannerMock,
Expand Down Expand Up @@ -104,6 +106,11 @@ jest.unstable_mockModule("node:child_process", () => ({
spawn: spawnMock,
}));

jest.unstable_mockModule("../src/output/html-reporter.js", () => ({
buildReportData: buildReportDataMock,
writeHtmlReport: writeHtmlReportMock,
}));

function createScanInput(overrides?: Partial<ScanInput>): ScanInput {
return {
mode: "manifest-fallback",
Expand Down Expand Up @@ -202,6 +209,8 @@ describe("CLI integration", () => {
package: finding.pkg.name,
severity: finding.severity,
}));
buildReportDataMock.mockReturnValue({ cliVersion: "1.8.0", findings: [] });
writeHtmlReportMock.mockResolvedValue({ reportPath: "/tmp/cve-report/index.html" });
});

it("returns a json payload and exits successfully when no findings are present", async () => {
Expand Down Expand Up @@ -549,4 +558,81 @@ describe("CLI integration", () => {
expect(result.exitCode).toBe(1);
expect(result.stderr.join("\n")).toContain("--fix cannot be used with --json");
});

describe("--report flag", () => {
it("calls writeHtmlReport and prints the report path", async () => {
const packages = [
{ name: "lodash", version: "4.17.21", ecosystem: "npm", paths: [["project", "lodash"]] },
];
loadPackagesMock.mockReturnValue(createScanInput({ packages }));
scanPackagesMock.mockResolvedValue([]);
parseArgsMock.mockReturnValue({
command: "scan",
options: {
failOn: "critical",
batchSize: "100",
searchDepth: "4",
minSeverity: "medium",
report: "./my-report",
noOpen: true,
},
projectArg: ".",
});

const result = await runIndexModule();

expect(writeHtmlReportMock).toHaveBeenCalledWith(
expect.objectContaining({
outputDir: expect.stringContaining("my-report"),
autoOpen: false,
})
);
const output = result.stdout.join("\n");
expect(output).toContain("/tmp/cve-report/index.html");
});

it("throws when --report and --json are both set", async () => {
parseArgsMock.mockReturnValue({
command: "scan",
options: {
failOn: "critical",
batchSize: "100",
searchDepth: "4",
minSeverity: "medium",
report: true,
json: true,
},
projectArg: ".",
});

const result = await runIndexModule();

expect(result.stderr.join("\n")).toContain("--report cannot be used with --json");
});

it("uses ./cve-report as default output dir when --report is true (boolean)", async () => {
const packages = [
{ name: "lodash", version: "4.17.21", ecosystem: "npm", paths: [["project", "lodash"]] },
];
loadPackagesMock.mockReturnValue(createScanInput({ packages }));
scanPackagesMock.mockResolvedValue([]);
parseArgsMock.mockReturnValue({
command: "scan",
options: {
failOn: "critical",
batchSize: "100",
searchDepth: "4",
minSeverity: "medium",
report: true,
noOpen: true,
},
projectArg: ".",
});

await runIndexModule();

const callArgs = writeHtmlReportMock.mock.calls[0][0];
expect(callArgs.outputDir).toContain("cve-report");
});
});
});
Loading
Loading