diff --git a/src/output/html-reporter.ts b/src/output/html-reporter.ts index c49f32b9..1b1ea6f1 100644 --- a/src/output/html-reporter.ts +++ b/src/output/html-reporter.ts @@ -279,7 +279,16 @@ button.header-link:hover{color:var(--link);border-color:var(--link)} .priority-legend-item{display:flex;align-items:flex-start;gap:10px;font-size:12px;color:var(--text-2);line-height:1.5} .sev-dev-note{text-align:center;font-size:12px;color:var(--text-2);margin:-12px 0 16px;padding:0 32px}`; -export function renderHtmlReport(data: ReportData): string { +function embedJson(value: unknown): string { + return JSON.stringify(value) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +export function renderHtmlReport(data: ReportData, jsonPayload?: object): string { const projectName = path.basename(data.projectPath); const scanDate = new Date(data.scannedAt).toLocaleString(); @@ -307,12 +316,7 @@ export function renderHtmlReport(data: ReportData): string { const skippedDepsHtml = renderSkippedDependenciesSection(data.skippedDependencies); const duplicatePackagesHtml = renderDuplicatePackagesSection(data.findings); const findingRowsHtml = data.findings.map((f, i) => renderFindingRow(f, i, skippedKeys)).join("\n"); - const dataJson = JSON.stringify(data) - .replace(//g, "\\u003e") - .replace(/&/g, "\\u0026") - .replace(/\u2028/g, "\\u2028") - .replace(/\u2029/g, "\\u2029"); + const dataJson = embedJson(jsonPayload ?? data); return ` @@ -1031,6 +1035,8 @@ export function renderFixPlan(plan: SuggestedFixCommandPlan | null): string { export async function writeHtmlReport(params: { outputDir: string; data: ReportData; + /** Same object `--json` writes. Stored as report.json and offered as the in-page download. */ + jsonPayload: object; autoOpen: boolean; }): Promise<{ reportPath: string }> { const dirExisted = fs.existsSync(params.outputDir); @@ -1044,8 +1050,8 @@ export async function writeHtmlReport(params: { const jsonPath = path.join(params.outputDir, "report.json"); try { - fs.writeFileSync(jsonPath, JSON.stringify(params.data, null, 2), "utf8"); - fs.writeFileSync(indexPath, renderHtmlReport(params.data), "utf8"); + fs.writeFileSync(jsonPath, JSON.stringify(params.jsonPayload, null, 2), "utf8"); + fs.writeFileSync(indexPath, renderHtmlReport(params.data, params.jsonPayload), "utf8"); } catch (err) { if (!dirExisted) { try { fs.rmSync(params.outputDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } diff --git a/src/output/multi-folder-html-reporter.ts b/src/output/multi-folder-html-reporter.ts index b2e60564..75d88eee 100644 --- a/src/output/multi-folder-html-reporter.ts +++ b/src/output/multi-folder-html-reporter.ts @@ -17,6 +17,7 @@ import { renderOverrideFindingsHtml } from "./override-findings-html.js"; import { renderMaintenanceFindingsHtml } from "./maintenance-findings-html.js"; import { renderLicenseFindingsHtml } from "./license-findings-html.js"; import { loadLogoDataUri } from "./logos.js"; +import { buildMultiFolderScanJson } from "./scan-json.js"; import { pluralize } from "../utils/string.js"; import type { SeverityLabel } from "../types.js"; import type { OverrideFinding } from "../overrides/types.js"; @@ -483,9 +484,24 @@ document.addEventListener('click', function(e) { `; + // Same --json multi-folder shape, plus cliVersion for saved-artifact provenance + // (scannedAt is already on the multi-folder payload) and per-finding + // riskSummary/nextAction from serializeHtmlFinding. + const jsonPayload = { + ...buildMultiFolderScanJson({ + results: params.results, + projectPath: params.projectPath, + includeOverrides: params.includeOverrides, + includeMaintenance: params.includeMaintenance, + includeLicenses: params.includeLicenses, + }), + cliVersion: params.cliVersion, + findings: reportData.findings, + }; + const indexPath = path.join(params.outputDir, "index.html"); const jsonPath = path.join(params.outputDir, "report.json"); - fs.writeFileSync(jsonPath, JSON.stringify(reportData, null, 2), "utf8"); + fs.writeFileSync(jsonPath, JSON.stringify(jsonPayload, null, 2), "utf8"); fs.writeFileSync(indexPath, html, "utf8"); if (params.autoOpen) openInBrowser(indexPath); diff --git a/src/output/scan-json.ts b/src/output/scan-json.ts new file mode 100644 index 00000000..87060e4a --- /dev/null +++ b/src/output/scan-json.ts @@ -0,0 +1,153 @@ +import type { Finding, ScanCompleteness, ScanInput } from "../types.js"; +import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; +import type { OverrideFinding } from "../overrides/types.js"; +import type { MaintenanceFinding } from "../maintenance/types.js"; +import type { LicenseFinding } from "../licenses/types.js"; +import { serializeFinding } from "./formatters.js"; +import { overrideFindingsToJson } from "./override-findings-json.js"; +import { maintenanceFindingsToJson } from "./maintenance-findings-json.js"; +import { serializeLicenseFindings } from "./license-findings-json.js"; +import { aggregateMultiFolderCompleteness } from "../scan/completeness.js"; + +export type ScanJsonFinding = ReturnType; + +export type ScanJsonInput = { + projectPath: string; + scanInput: ScanInput; + findings: Finding[]; + suggestedFixCommands: SuggestedFixCommandPlan | null; + coverage: string[]; + completeness: ScanCompleteness; + overrideFindings?: ReadonlyArray; + maintenanceFindings?: ReadonlyArray; + licenseFindings?: ReadonlyArray; + licenseLimitedToDirectOnly?: boolean; +}; + +export type ScanJsonPayload = { + projectPath: string; + mode: ScanInput["mode"]; + source: ScanInput["source"]; + packageCount: number; + findingCount: number; + status: "ok" | "partial"; + complete: boolean; + diagnostics: ScanCompleteness["diagnostics"]; + suggestedFixCommands: SuggestedFixCommandPlan | null; + notes: string[]; + warnings: string[]; + skippedDependencies: string[]; + findings: ScanJsonFinding[]; + overrideFindings: OverrideFinding[]; + maintenanceFindings: MaintenanceFinding[]; + licenseFindings?: ReturnType; + licenseLimitedToDirectOnly?: boolean; +}; + +/** + * Single-folder `--json` payload. HTML `report.json` reuses this so both + * artifacts share one schema. + */ +export function buildScanJson(input: ScanJsonInput): ScanJsonPayload { + const payload = { + projectPath: input.projectPath, + mode: input.scanInput.mode, + source: input.scanInput.source, + packageCount: input.scanInput.packages.length, + findingCount: input.findings.length, + status: (input.completeness.complete ? "ok" : "partial") as "ok" | "partial", + complete: input.completeness.complete, + diagnostics: input.completeness.diagnostics, + suggestedFixCommands: input.suggestedFixCommands, + notes: [...input.scanInput.notes, ...input.coverage], + warnings: input.scanInput.warnings, + skippedDependencies: input.scanInput.skippedDependencies, + findings: input.findings.map(finding => serializeFinding(finding, input.suggestedFixCommands)), + }; + const overridePayload = overrideFindingsToJson(input.overrideFindings ?? []); + const maintenancePayload = maintenanceFindingsToJson(input.maintenanceFindings ?? []); + const licensePayload = input.licenseFindings && input.licenseFindings.length > 0 + ? { + licenseFindings: serializeLicenseFindings(input.licenseFindings), + ...(input.licenseLimitedToDirectOnly ? { licenseLimitedToDirectOnly: true } : {}), + } + : {}; + return { ...payload, ...overridePayload, ...maintenancePayload, ...licensePayload }; +} + +export type MultiFolderScanJsonFolder = { + subfolder: string; + sorted: Finding[]; + suggestedFixCommands: SuggestedFixCommandPlan | null; + completeness: ScanCompleteness; + overrideFindings: OverrideFinding[]; + maintenanceFindings: MaintenanceFinding[]; + licenseFindings: LicenseFinding[]; + scanInput: Pick; +}; + +export type MultiFolderScanJsonPayload = { + multiFolder: true; + projectPath: string; + folders: string[]; + findingCount: number; + status: "ok" | "partial"; + complete: boolean; + diagnostics: ScanCompleteness["diagnostics"]; + warnings: string[]; + findings: Array; + overrideFindings?: Array; + maintenanceFindings?: Array; + licenseFindings?: Array; + scannedAt: string; +}; + +/** + * Multi-folder `--json` payload. HTML `report.json` reuses this so both + * artifacts share one schema. + */ +export function buildMultiFolderScanJson(params: { + results: ReadonlyArray; + projectPath: string; + includeOverrides?: boolean; + includeMaintenance?: boolean; + includeLicenses?: boolean; +}): MultiFolderScanJsonPayload { + const aggregatedCompleteness = aggregateMultiFolderCompleteness(params.results); + const findings = params.results.flatMap(r => + r.sorted.map(f => ({ ...serializeFinding(f, r.suggestedFixCommands), subfolder: r.subfolder })), + ); + return { + multiFolder: true, + projectPath: params.projectPath, + folders: params.results.map(r => r.subfolder), + findingCount: findings.length, + status: aggregatedCompleteness.complete ? "ok" : "partial", + complete: aggregatedCompleteness.complete, + diagnostics: aggregatedCompleteness.diagnostics, + warnings: params.results.flatMap(r => r.scanInput.warnings), + findings, + ...(params.includeOverrides + ? { + overrideFindings: params.results.flatMap(r => + r.overrideFindings.map(f => ({ ...f, subfolder: r.subfolder })), + ), + } + : {}), + ...(params.includeMaintenance + ? { + maintenanceFindings: params.results.flatMap(r => + r.maintenanceFindings.map(f => ({ ...f, subfolder: r.subfolder })), + ), + } + : {}), + ...(params.includeLicenses + ? { + licenseFindings: params.results.flatMap(r => + r.licenseFindings.map(f => ({ ...f, subfolder: r.subfolder })), + ), + } + : {}), + scannedAt: new Date().toISOString(), + }; +} diff --git a/src/output/write-outputs.ts b/src/output/write-outputs.ts index a8420fce..585943b8 100644 --- a/src/output/write-outputs.ts +++ b/src/output/write-outputs.ts @@ -6,16 +6,13 @@ import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; import type { OverrideFinding } from "../overrides/types.js"; import type { MaintenanceFinding } from "../maintenance/types.js"; import type { LicenseFinding } from "../licenses/types.js"; -import { serializeLicenseFindings } from "./license-findings-json.js"; import type { ProjectMeta } from "./cyclonedx.js"; -import { serializeFinding } from "./formatters.js"; import { writeSarifReport, deriveLockfileUri } from "./sarif.js"; import { writeCycloneDxReport } from "./cyclonedx.js"; import { writeSpdxReport } from "./spdx.js"; import { resolveDependencyEdges } from "./sbom-dependency-edges.js"; import { resolveSbomFormat } from "../utils/sbom-format.js"; -import { overrideFindingsToJson } from "./override-findings-json.js"; -import { maintenanceFindingsToJson } from "./maintenance-findings-json.js"; +import { buildScanJson } from "./scan-json.js"; import { resolveOutputDir, displayOutputPath } from "./output-dir.js"; export type ScanState = { @@ -63,31 +60,19 @@ export async function writeOutputs( const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const jsonFilename = `cve-lite-scan-${ts}.json`; const jsonOutputPath = path.join(outputDir, jsonFilename); - const payload = { + const payload = buildScanJson({ projectPath, - mode: scanInput.mode, - source: scanInput.source, - packageCount: scanInput.packages.length, - findingCount: scanState.sorted.length, - status: scanState.completeness.complete ? "ok" : "partial", - complete: scanState.completeness.complete, - diagnostics: scanState.completeness.diagnostics, + scanInput, + findings: scanState.sorted, suggestedFixCommands: scanState.suggestedFixCommands, - notes: [...scanInput.notes, ...scanState.coverage], - warnings: scanInput.warnings, - skippedDependencies: scanInput.skippedDependencies, - findings: scanState.sorted.map(finding => serializeFinding(finding, scanState.suggestedFixCommands)), - }; - const overridePayload = overrideFindingsToJson(scanState.overrideFindings ?? []); - const maintenancePayload = maintenanceFindingsToJson(scanState.maintenanceFindings ?? []); - const licensePayload = scanState.licenseFindings && scanState.licenseFindings.length > 0 - ? { - licenseFindings: serializeLicenseFindings(scanState.licenseFindings), - ...(scanState.licenseLimitedToDirectOnly ? { licenseLimitedToDirectOnly: true } : {}), - } - : {}; - const combined = { ...payload, ...overridePayload, ...maintenancePayload, ...licensePayload }; - fs.writeFileSync(jsonOutputPath, JSON.stringify(combined, null, 2)); + coverage: scanState.coverage, + completeness: scanState.completeness, + overrideFindings: scanState.overrideFindings, + maintenanceFindings: scanState.maintenanceFindings, + licenseFindings: scanState.licenseFindings, + licenseLimitedToDirectOnly: scanState.licenseLimitedToDirectOnly, + }); + fs.writeFileSync(jsonOutputPath, JSON.stringify(payload, null, 2)); console.log(`${chalk.gray("JSON saved to")} ${chalk.cyan(displayOutputPath(jsonOutputPath))}`); } diff --git a/src/scan/multi-folder-scan.ts b/src/scan/multi-folder-scan.ts index 81d8f643..d90e7fc4 100644 --- a/src/scan/multi-folder-scan.ts +++ b/src/scan/multi-folder-scan.ts @@ -7,7 +7,8 @@ import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; import type { ScanInput } from "../types.js"; import { loadMultiplePackages } from "../parsers/multi-package.js"; import { scanPackages, buildCoverageNotes } from "../scanner.js"; -import { sortFindingsForOutput, serializeFinding, logInfo } from "../output/formatters.js"; +import { sortFindingsForOutput, logInfo } from "../output/formatters.js"; +import { buildMultiFolderScanJson } from "../output/scan-json.js"; import { resolveOutputDir, displayOutputPath } from "../output/output-dir.js"; import { normalizeSeverity } from "../osv/severity.js"; import { selectFindingsForTable } from "../output/finding-display.js"; @@ -367,42 +368,13 @@ export async function handleMultiFolderScan(params: { if (params.options.json) { // Match single-folder writeOutputs: timestamped file in cwd, not stdout dump. // Help text and CI both treat --json as "save a JSON file". - const allFindings = results.flatMap(r => - r.sorted.map(f => ({ ...serializeFinding(f, r.suggestedFixCommands), subfolder: r.subfolder })), - ); - const payload = { - multiFolder: true as const, + const payload = buildMultiFolderScanJson({ + results, projectPath: params.projectRoot, - folders: results.map(r => r.subfolder), - findingCount: allFindings.length, - status: aggregatedCompleteness.complete ? "ok" : "partial", - complete: aggregatedCompleteness.complete, - diagnostics: aggregatedCompleteness.diagnostics, - warnings: results.flatMap(r => r.scanInput.warnings), - findings: allFindings, - ...(params.options.checkOverrides - ? { - overrideFindings: results.flatMap(r => - r.overrideFindings.map(f => ({ ...f, subfolder: r.subfolder })), - ), - } - : {}), - ...(params.options.checkMaintenance - ? { - maintenanceFindings: results.flatMap(r => - r.maintenanceFindings.map(f => ({ ...f, subfolder: r.subfolder })), - ), - } - : {}), - ...(params.options.checkLicenses - ? { - licenseFindings: results.flatMap(r => - r.licenseFindings.map(f => ({ ...f, subfolder: r.subfolder })), - ), - } - : {}), - scannedAt: new Date().toISOString(), - }; + includeOverrides: !!params.options.checkOverrides, + includeMaintenance: !!params.options.checkMaintenance, + includeLicenses: !!params.options.checkLicenses, + }); const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const jsonFilename = `cve-lite-scan-${ts}.json`; const jsonOutputPath = path.join(resolveOutputDir(params.options.output), jsonFilename); diff --git a/src/scan/single-scan.ts b/src/scan/single-scan.ts index a1217f6a..f6ca6da5 100644 --- a/src/scan/single-scan.ts +++ b/src/scan/single-scan.ts @@ -22,6 +22,7 @@ import { } from "../output/formatters.js"; import { countBySeverity, reachesFailOn } from "../utils/severity.js"; import { buildReportData, writeHtmlReport } from "../output/html-reporter.js"; +import { buildScanJson } from "../output/scan-json.js"; import { writeOutputs } from "../output/write-outputs.js"; import { selectFindingsForTable } from "../output/finding-display.js"; import { @@ -626,9 +627,30 @@ export async function handleSingleFolderScan(params: SingleFolderScanParams): Pr licenseLimitedToDirectOnly: options.checkLicenses ? licenseLimitedToDirectOnly : undefined, completeness: scanState.completeness, }); + // report.json follows the --json schema, plus scannedAt/cliVersion for a + // saved artifact and per-finding riskSummary/nextAction (stdout --json + // stays unchanged via buildScanJson alone). + const jsonPayload = { + ...buildScanJson({ + projectPath, + scanInput, + findings: scanState.sorted, + suggestedFixCommands: scanState.suggestedFixCommands, + coverage: scanState.coverage, + completeness: scanState.completeness, + overrideFindings, + maintenanceFindings, + licenseFindings, + licenseLimitedToDirectOnly, + }), + scannedAt: reportData.scannedAt, + cliVersion: reportData.cliVersion, + findings: reportData.findings, + }; const { reportPath } = await writeHtmlReport({ outputDir, data: reportData, + jsonPayload, autoOpen: !options.noOpen, }); console.log(`${chalk.gray("Report:")} ${chalk.cyan(reportPath)}`); diff --git a/tests/e2e/validation-and-outputs.test.ts b/tests/e2e/validation-and-outputs.test.ts index 8733e727..a13a6ae7 100644 --- a/tests/e2e/validation-and-outputs.test.ts +++ b/tests/e2e/validation-and-outputs.test.ts @@ -173,6 +173,63 @@ describe("output channels", () => { } }); + // --report and --json cannot run together. report.json matches the --json + // schema for the same scan, plus scannedAt/cliVersion provenance on the + // saved artifact and per-finding riskSummary/nextAction (stdout --json + // stays without those fields). Uses a known-vulnerable examples/ fixture so + // the findings comparison is not vacuously empty. + it("HTML report.json matches the --json schema plus scannedAt/cliVersion", () => { + const proj = join(process.cwd(), "examples", "direct-fixable"); + if (!existsSync(proj)) { + console.log("skip: examples/direct-fixable absent"); + return; + } + const jsonCwd = mkProject({}); + const reportCwd = mkProject({}); + try { + const jsonRun = runCli([proj, "--offline", "--json", "--check-overrides"], { cwd: jsonCwd, env: CI_ENV }); + // Findings exist, so fail-on may trip a non-zero exit; the payloads are + // still written. status -1 would mean the process was killed. + expect(jsonRun.status).not.toBe(-1); + const jsonFiles = findByExt(jsonCwd, ".json").filter(f => f.startsWith("cve-lite-scan-")); + expect(jsonFiles).toHaveLength(1); + const jsonPayload = JSON.parse(readFileSync(join(jsonCwd, jsonFiles[0]), "utf8")); + + const reportRun = runCli( + [proj, "--offline", "--report", "./rep", "--no-open", "--check-overrides"], + { cwd: reportCwd, env: CI_ENV }, + ); + expect(reportRun.status).not.toBe(-1); + const reportPayload = JSON.parse(readFileSync(join(reportCwd, "rep", "report.json"), "utf8")); + + expect(jsonPayload.findingCount).toBeGreaterThan(0); + expect(reportPayload.findingCount).toBeGreaterThan(0); + + const { scannedAt: _reportAt, cliVersion: reportCliVersion, ...reportRest } = reportPayload; + expect(Object.keys(reportRest).sort()).toEqual(Object.keys(jsonPayload).sort()); + expect(reportRest).toMatchObject({ + projectPath: jsonPayload.projectPath, + mode: jsonPayload.mode, + source: jsonPayload.source, + packageCount: jsonPayload.packageCount, + findingCount: jsonPayload.findingCount, + findings: jsonPayload.findings, + overrideFindings: jsonPayload.overrideFindings, + }); + expect(typeof _reportAt).toBe("string"); + expect(typeof reportCliVersion).toBe("string"); + expect(jsonPayload).not.toHaveProperty("cliVersion"); + expect(jsonPayload).not.toHaveProperty("scannedAt"); + expect(jsonPayload.findings[0]).not.toHaveProperty("riskSummary"); + expect(jsonPayload.findings[0]).not.toHaveProperty("nextAction"); + expect(reportPayload.findings[0]).toHaveProperty("riskSummary"); + expect(reportPayload.findings[0]).toHaveProperty("nextAction"); + } finally { + rmProject(jsonCwd); + rmProject(reportCwd); + } + }); + // --report ./rep --no-open writes an HTML report (rep/index.html). With // --check-overrides the "Override hygiene" section is populated. it("--report writes HTML containing an Override hygiene section", () => { diff --git a/tests/html-reporter.test.ts b/tests/html-reporter.test.ts index 678b61fe..8e0f7c0b 100644 --- a/tests/html-reporter.test.ts +++ b/tests/html-reporter.test.ts @@ -13,7 +13,8 @@ import { writeHtmlReport, } from "../src/output/html-reporter.js"; import { formatCooldownWarning } from "../src/output/formatters.js"; -import type { Finding, OsvVuln } from "../src/types.js"; +import { buildScanJson } from "../src/output/scan-json.js"; +import type { Finding, OsvVuln, ScanInput } from "../src/types.js"; import type { SuggestedFixCommandPlan } from "../src/remediation/fix-commands.js"; function makeVuln(overrides?: Partial): OsvVuln { @@ -52,6 +53,41 @@ const BASE_PARAMS = { skippedDependencies: [], }; +const BASE_SCAN_INPUT: ScanInput = { + mode: "resolved-lockfile", + source: "package-lock", + filePath: "/home/user/my-app/package-lock.json", + packages: Array.from({ length: 42 }, (_, i) => ({ + name: `pkg-${i}`, + version: "1.0.0", + ecosystem: "npm", + })), + notes: ["note1"], + warnings: [], + skippedDependencies: [], +}; + +function sampleJsonPayload(findings: Finding[] = BASE_PARAMS.findings) { + return buildScanJson({ + projectPath: BASE_PARAMS.projectPath, + scanInput: BASE_SCAN_INPUT, + findings, + suggestedFixCommands: null, + coverage: [], + completeness: { complete: true, diagnostics: [] }, + }); +} + +function extractEmbeddedReportData(html: string): unknown { + const marker = "const reportData = "; + const start = html.indexOf(marker); + expect(start).toBeGreaterThan(-1); + const jsonStart = start + marker.length; + const end = html.indexOf(";\nconst SEV_ORDER", jsonStart); + expect(end).toBeGreaterThan(jsonStart); + return JSON.parse(html.slice(jsonStart, end)); +} + describe("buildReportData", () => { it("maps scalar fields verbatim", () => { const data = buildReportData(BASE_PARAMS); @@ -280,6 +316,12 @@ describe("renderHtmlReport", () => { expect(html).toContain("cve-lite-report.json"); }); + it("embeds the --json payload for download when jsonPayload is provided", () => { + const jsonPayload = sampleJsonPayload(); + const html = renderHtmlReport(buildReportData(BASE_PARAMS), jsonPayload); + expect(extractEmbeddedReportData(html)).toEqual(JSON.parse(JSON.stringify(jsonPayload))); + }); + it("embeds cliVersion in the footer", () => { expect(renderHtmlReport(data)).toContain("1.8.0"); }); @@ -1095,40 +1137,71 @@ describe("writeHtmlReport", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + function writeReport( + outputDir: string, + data = buildReportData(BASE_PARAMS), + jsonPayload: object = sampleJsonPayload(), + ) { + return writeHtmlReport({ outputDir, data, jsonPayload, autoOpen: false }); + } + it("creates the output directory if it does not exist", async () => { const outputDir = path.join(tmpDir, "nested", "report"); - const data = buildReportData(BASE_PARAMS); - await writeHtmlReport({ outputDir, data, autoOpen: false }); + await writeReport(outputDir); expect(fs.existsSync(outputDir)).toBe(true); }); it("writes index.html to the output directory", async () => { const outputDir = path.join(tmpDir, "report"); - const data = buildReportData(BASE_PARAMS); - await writeHtmlReport({ outputDir, data, autoOpen: false }); + await writeReport(outputDir); expect(fs.existsSync(path.join(outputDir, "index.html"))).toBe(true); }); it("writes report.json to the output directory", async () => { const outputDir = path.join(tmpDir, "report"); - const data = buildReportData(BASE_PARAMS); - await writeHtmlReport({ outputDir, data, autoOpen: false }); + await writeReport(outputDir); expect(fs.existsSync(path.join(outputDir, "report.json"))).toBe(true); }); - it("report.json is valid JSON matching ReportData", async () => { + it("report.json matches the --json payload plus scannedAt/cliVersion provenance", async () => { const outputDir = path.join(tmpDir, "report"); const data = buildReportData(BASE_PARAMS); - await writeHtmlReport({ outputDir, data, autoOpen: false }); + const jsonPayload = { + ...sampleJsonPayload(), + scannedAt: data.scannedAt, + cliVersion: data.cliVersion, + findings: data.findings, + }; + await writeReport(outputDir, data, jsonPayload); const json = JSON.parse(fs.readFileSync(path.join(outputDir, "report.json"), "utf8")); + expect(json).toEqual(JSON.parse(JSON.stringify(jsonPayload))); expect(json.cliVersion).toBe("1.8.0"); - expect(json.findings).toHaveLength(1); + expect(typeof json.scannedAt).toBe("string"); + expect(json).not.toHaveProperty("packageManager"); + expect(json).not.toHaveProperty("lockfileSource"); + expect(json.findings[0]).toHaveProperty("riskSummary"); + expect(json.findings[0]).toHaveProperty("nextAction"); + expect(json).toMatchObject({ + projectPath: BASE_PARAMS.projectPath, + mode: "resolved-lockfile", + source: "package-lock", + packageCount: 42, + findingCount: 1, + findings: expect.any(Array), + }); + }); + + it("embeds the same --json payload in index.html for download", async () => { + const outputDir = path.join(tmpDir, "report"); + const jsonPayload = sampleJsonPayload(); + await writeReport(outputDir, buildReportData(BASE_PARAMS), jsonPayload); + const html = fs.readFileSync(path.join(outputDir, "index.html"), "utf8"); + expect(extractEmbeddedReportData(html)).toEqual(JSON.parse(JSON.stringify(jsonPayload))); }); it("returns the reportPath as the absolute path to index.html", async () => { const outputDir = path.join(tmpDir, "report"); - const data = buildReportData(BASE_PARAMS); - const result = await writeHtmlReport({ outputDir, data, autoOpen: false }); + const result = await writeReport(outputDir); expect(result.reportPath).toBe(path.join(outputDir, "index.html")); }); @@ -1136,10 +1209,10 @@ describe("writeHtmlReport", () => { const outputDir = path.join(tmpDir, "report"); const data1 = buildReportData({ ...BASE_PARAMS, cliVersion: "1.0.0" }); const data2 = buildReportData({ ...BASE_PARAMS, cliVersion: "2.0.0" }); - await writeHtmlReport({ outputDir, data: data1, autoOpen: false }); - await writeHtmlReport({ outputDir, data: data2, autoOpen: false }); + await writeReport(outputDir, data1, { findingCount: 1 }); + await writeReport(outputDir, data2, { findingCount: 2 }); const json = JSON.parse(fs.readFileSync(path.join(outputDir, "report.json"), "utf8")); - expect(json.cliVersion).toBe("2.0.0"); + expect(json.findingCount).toBe(2); }); it("throws with descriptive path when mkdirSync fails", async () => { @@ -1148,9 +1221,7 @@ describe("writeHtmlReport", () => { throw new Error("ENOSPC: no space left on device"); }); try { - const data = buildReportData(BASE_PARAMS); - await expect(writeHtmlReport({ outputDir: brokenDir, data, autoOpen: false })) - .rejects.toThrow(/Failed to create report directory/); + await expect(writeReport(brokenDir)).rejects.toThrow(/Failed to create report directory/); } finally { writeSpy.mockRestore(); } @@ -1162,9 +1233,7 @@ describe("writeHtmlReport", () => { throw new Error("ENOSPC: no space left on device"); }); try { - const data = buildReportData(BASE_PARAMS); - await expect(writeHtmlReport({ outputDir, data, autoOpen: false })) - .rejects.toThrow(/Failed to write HTML report/); + await expect(writeReport(outputDir)).rejects.toThrow(/Failed to write HTML report/); expect(fs.existsSync(outputDir)).toBe(false); } finally { writeSpy.mockRestore(); @@ -1184,9 +1253,7 @@ describe("writeHtmlReport", () => { return undefined; }); try { - const data = buildReportData(BASE_PARAMS); - await expect(writeHtmlReport({ outputDir, data, autoOpen: false })) - .rejects.toThrow(/Failed to write HTML report/); + await expect(writeReport(outputDir)).rejects.toThrow(/Failed to write HTML report/); expect(fs.existsSync(outputDir)).toBe(true); expect(fs.existsSync(marker)).toBe(true); } finally { @@ -1201,10 +1268,9 @@ describe("writeHtmlReport", () => { throw originalError; }); try { - const data = buildReportData(BASE_PARAMS); let thrown: unknown; try { - await writeHtmlReport({ outputDir, data, autoOpen: false }); + await writeReport(outputDir); } catch (err) { thrown = err; } diff --git a/tests/multi-folder-html-reporter.test.ts b/tests/multi-folder-html-reporter.test.ts index 72a0819e..a96859dd 100644 --- a/tests/multi-folder-html-reporter.test.ts +++ b/tests/multi-folder-html-reporter.test.ts @@ -7,6 +7,7 @@ import { buildMultiFolderReportData, writeMultiFolderHtmlReport, } from "../src/output/multi-folder-html-reporter.js"; +import { buildMultiFolderScanJson } from "../src/output/scan-json.js"; import type { SerializedFinding } from "../src/output/html-reporter.js"; import type { MultiFolderScanResult } from "../src/scan/multi-folder-scan.js"; import type { Finding, ScanCompleteness } from "../src/types.js"; @@ -216,10 +217,11 @@ describe("writeMultiFolderHtmlReport", () => { expect(fs.existsSync(path.join(outputDir, "report.json"))).toBe(true); }); - it("report.json matches MultiFolderReportData shape", async () => { + it("report.json matches the multi-folder --json shape", async () => { + const results = [makeResult("packages/a"), makeResult("packages/b")]; await writeMultiFolderHtmlReport({ outputDir, - results: [makeResult("packages/a"), makeResult("packages/b")], + results, projectPath: "/project", cliVersion: "1.27.0", autoOpen: false, @@ -229,8 +231,33 @@ describe("writeMultiFolderHtmlReport", () => { expect(json.multiFolder).toBe(true); expect(json.folders).toEqual(["packages/a", "packages/b"]); expect(json.findings).toHaveLength(2); + expect(json.findingCount).toBe(2); + expect(json.status).toBe("ok"); + expect(json.complete).toBe(true); + expect(json.diagnostics).toEqual([]); + expect(json).toHaveProperty("scannedAt"); expect(json.cliVersion).toBe("1.27.0"); + expect(json.findings[0]).toHaveProperty("subfolder", "packages/a"); + expect(json.findings[0]).toHaveProperty("riskSummary"); + expect(json.findings[0]).toHaveProperty("nextAction"); expect(json.overrideFindings).toBeUndefined(); + + const expected = { + ...buildMultiFolderScanJson({ + results, + projectPath: "/project", + }), + cliVersion: "1.27.0", + findings: results.flatMap(r => + r.sorted.map(f => ({ + ...serializeHtmlFinding(f, r.suggestedFixCommands), + subfolder: r.subfolder, + })), + ), + }; + const { scannedAt: _writtenAt, ...writtenRest } = json; + const { scannedAt: _expectedAt, ...expectedRest } = JSON.parse(JSON.stringify(expected)); + expect(writtenRest).toEqual(expectedRest); }); it("renders override and maintenance panels when flags are set", async () => { diff --git a/tests/output/scan-json.test.ts b/tests/output/scan-json.test.ts new file mode 100644 index 00000000..52d509db --- /dev/null +++ b/tests/output/scan-json.test.ts @@ -0,0 +1,193 @@ +import { buildMultiFolderScanJson, buildScanJson } from "../../src/output/scan-json.js"; +import type { Finding, ScanInput } from "../../src/types.js"; +import type { LicenseFinding } from "../../src/licenses/types.js"; +import type { MaintenanceFinding } from "../../src/maintenance/types.js"; +import type { OverrideFinding } from "../../src/overrides/types.js"; + +function makeFinding(): Finding { + return { + pkg: { name: "lodash", version: "4.17.20", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-xxx", aliases: ["CVE-2021-23337"], summary: "test" }], + severity: "high", + cveAliases: ["CVE-2021-23337"], + dependencyPaths: [["project", "lodash"]], + relationship: "direct", + firstFixedVersion: "4.17.21", + }; +} + +const scanInput: ScanInput = { + mode: "resolved-lockfile", + source: "package-lock", + filePath: "/tmp/project/package-lock.json", + packages: [{ name: "lodash", version: "4.17.20", ecosystem: "npm" }], + notes: ["from-scan"], + warnings: ["warn"], + skippedDependencies: ["skipped"], +}; + +describe("buildScanJson", () => { + it("emits the --json envelope with serialized findings", () => { + const payload = buildScanJson({ + projectPath: "/tmp/project", + scanInput, + findings: [makeFinding()], + suggestedFixCommands: null, + coverage: ["coverage-note"], + completeness: { complete: true, diagnostics: [] }, + }); + + expect(payload).toMatchObject({ + projectPath: "/tmp/project", + mode: "resolved-lockfile", + source: "package-lock", + packageCount: 1, + findingCount: 1, + status: "ok", + complete: true, + diagnostics: [], + suggestedFixCommands: null, + notes: ["from-scan", "coverage-note"], + warnings: ["warn"], + skippedDependencies: ["skipped"], + }); + expect(payload.findings).toHaveLength(1); + expect(payload.findings[0].package).toBe("lodash"); + expect(payload.findings[0]).not.toHaveProperty("riskSummary"); + expect(payload.findings[0]).not.toHaveProperty("nextAction"); + expect(payload.overrideFindings).toEqual([]); + expect(payload.maintenanceFindings).toEqual([]); + expect(payload).not.toHaveProperty("licenseFindings"); + expect(payload).not.toHaveProperty("cliVersion"); + expect(payload).not.toHaveProperty("scannedAt"); + }); + + it("includes licenseFindings only when the list is non-empty", () => { + const license: LicenseFinding = { + ruleId: "LC001", + severity: "medium", + package: { name: "copyleft", version: "1.0.0" }, + license: "GPL-3.0", + relationship: "direct", + message: "copyleft", + }; + const empty = buildScanJson({ + projectPath: "/tmp/project", + scanInput, + findings: [], + suggestedFixCommands: null, + coverage: [], + completeness: { complete: true, diagnostics: [] }, + licenseFindings: [], + }); + expect(empty).not.toHaveProperty("licenseFindings"); + + const withLicenses = buildScanJson({ + projectPath: "/tmp/project", + scanInput, + findings: [], + suggestedFixCommands: null, + coverage: [], + completeness: { complete: true, diagnostics: [] }, + licenseFindings: [license], + licenseLimitedToDirectOnly: true, + }); + expect(withLicenses.licenseFindings).toHaveLength(1); + expect(withLicenses.licenseLimitedToDirectOnly).toBe(true); + }); + + it("sets status partial when completeness.complete is false", () => { + const payload = buildScanJson({ + projectPath: "/tmp/project", + scanInput, + findings: [], + suggestedFixCommands: null, + coverage: [], + completeness: { + complete: false, + diagnostics: [{ + code: "PACKUMENT_FETCH_FAILURE", + severity: "warning", + message: "1 packument fetch failed", + impact: "remediation", + count: 1, + }], + }, + }); + expect(payload.status).toBe("partial"); + expect(payload.complete).toBe(false); + expect(payload.diagnostics).toHaveLength(1); + }); +}); + +describe("buildMultiFolderScanJson", () => { + const override: OverrideFinding = { + ruleId: "OA001", + severity: "high", + package: { name: "gone" }, + location: { file: "package.json", jsonPath: "/overrides/gone" }, + message: "orphaned", + }; + const maintenance: MaintenanceFinding = { + ruleId: "DM001", + severity: "high", + package: { name: "gray-matter", version: "4.0.3" }, + drag: [], + message: "stale", + }; + + function folder(subfolder: string, warnings: string[] = [`warn-${subfolder}`]) { + return { + subfolder, + sorted: [makeFinding()], + suggestedFixCommands: null, + completeness: { complete: true, diagnostics: [] }, + overrideFindings: [override], + maintenanceFindings: [maintenance], + licenseFindings: [] as LicenseFinding[], + scanInput: { warnings }, + }; + } + + it("tags findings with subfolder and omits optional channels by default", () => { + const payload = buildMultiFolderScanJson({ + results: [folder("packages/a"), folder("packages/b")], + projectPath: "/project", + }); + expect(payload.multiFolder).toBe(true); + expect(payload.folders).toEqual(["packages/a", "packages/b"]); + expect(payload.findingCount).toBe(2); + expect(payload.findings[0].subfolder).toBe("packages/a"); + expect(payload.findings[0]).not.toHaveProperty("riskSummary"); + expect(payload.overrideFindings).toBeUndefined(); + expect(payload.maintenanceFindings).toBeUndefined(); + expect(payload.licenseFindings).toBeUndefined(); + expect(payload).not.toHaveProperty("cliVersion"); + expect(typeof payload.scannedAt).toBe("string"); + expect(payload.warnings).toEqual(["warn-packages/a", "warn-packages/b"]); + }); + + it("flattens per-folder scanInput.warnings in folder order", () => { + const payload = buildMultiFolderScanJson({ + results: [ + folder("packages/a", ["a-1", "a-2"]), + folder("packages/b", ["b-1"]), + ], + projectPath: "/project", + }); + expect(payload.warnings).toEqual(["a-1", "a-2", "b-1"]); + }); + + it("includes optional channels only when the matching flag is set", () => { + const payload = buildMultiFolderScanJson({ + results: [folder("packages/a")], + projectPath: "/project", + includeOverrides: true, + includeMaintenance: true, + includeLicenses: true, + }); + expect(payload.overrideFindings).toEqual([{ ...override, subfolder: "packages/a" }]); + expect(payload.maintenanceFindings).toEqual([{ ...maintenance, subfolder: "packages/a" }]); + expect(payload.licenseFindings).toEqual([]); + }); +}); diff --git a/tests/write-outputs.test.ts b/tests/write-outputs.test.ts index 03bac2cc..fec33e85 100644 --- a/tests/write-outputs.test.ts +++ b/tests/write-outputs.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { Finding, PackageRef, ParsedOptions, ScanInput } from "../src/types.js"; import type { ScanState } from "../src/output/write-outputs.js"; import type { MaintenanceFinding } from "../src/maintenance/types.js"; +import { buildScanJson } from "../src/output/scan-json.js"; const writeFileSyncMock = jest.fn(); const mkdirSyncMock = jest.fn(); @@ -140,6 +141,52 @@ describe("writeOutputs", () => { }); }); + it("JSON file matches buildScanJson and keeps the --json schema", async () => { + const finding: Finding = { + pkg: { name: "lodash", version: "4.17.20", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-xxx", aliases: ["CVE-2021-23337"], summary: "test" }], + severity: "high", + cveAliases: ["CVE-2021-23337"], + dependencyPaths: [["project", "lodash"]], + relationship: "direct", + firstFixedVersion: "4.17.21", + }; + const scanState = { + ...mockScanState, + sorted: [finding], + coverage: ["coverage-note"], + }; + const scanInput: ScanInput = { + ...mockScanInput, + packages: [finding.pkg], + notes: ["from-scan"], + warnings: ["warn"], + skippedDependencies: ["skipped-dep"], + }; + await writeOutputs(makeOptions({ json: true }), scanState, scanInput, "/tmp/project"); + const [, jsonContent] = writeFileSyncMock.mock.calls[0] as [string, string]; + const parsed = JSON.parse(jsonContent); + expect(parsed).toEqual(JSON.parse(JSON.stringify(buildScanJson({ + projectPath: "/tmp/project", + scanInput, + findings: scanState.sorted, + suggestedFixCommands: scanState.suggestedFixCommands, + coverage: scanState.coverage, + completeness: scanState.completeness, + })))); + expect(parsed.findings).toHaveLength(1); + expect(parsed.findings[0]).not.toHaveProperty("riskSummary"); + expect(parsed.findings[0]).not.toHaveProperty("nextAction"); + expect(parsed.notes).toContain("coverage-note"); + expect(parsed.notes).toContain("from-scan"); + expect(parsed.warnings).toEqual(["warn"]); + expect(parsed.skippedDependencies).toEqual(["skipped-dep"]); + expect(parsed).not.toHaveProperty("cliVersion"); + expect(parsed).not.toHaveProperty("scannedAt"); + expect(parsed).not.toHaveProperty("packageManager"); + expect(parsed).not.toHaveProperty("lockfileSource"); + }); + it("logs the saved filename to stdout when json: true", async () => { await writeOutputs(makeOptions({ json: true }), mockScanState, mockScanInput, "/tmp/project"); const logged = consoleSpy.mock.calls.map(call => call.join(" ")).join("\n"); diff --git a/website/docs/html-report.md b/website/docs/html-report.md index ce0f95ca..5999acb7 100644 --- a/website/docs/html-report.md +++ b/website/docs/html-report.md @@ -28,7 +28,7 @@ The report writes two files to the output directory: | File | Description | |---|---| | `index.html` | Self-contained dashboard. Open in any browser — no server required. | -| `report.json` | Machine-readable scan data in JSON format. | +| `report.json` | Machine-readable scan data. Same JSON schema as `--json`, plus `scannedAt` and `cliVersion` provenance for the saved artifact, and per-finding `riskSummary` and `nextAction`. | Running `--report` to the same directory a second time overwrites both files.