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
24 changes: 15 additions & 9 deletions src/output/html-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "\\u003c")
.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();
Expand Down Expand Up @@ -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, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
const dataJson = embedJson(jsonPayload ?? data);

return `<!DOCTYPE html>
<html lang="en">
Expand Down Expand Up @@ -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);
Expand All @@ -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 */ }
Expand Down
18 changes: 17 additions & 1 deletion src/output/multi-folder-html-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -483,9 +484,24 @@ document.addEventListener('click', function(e) {
</body>
</html>`;

// 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);
Expand Down
153 changes: 153 additions & 0 deletions src/output/scan-json.ts
Original file line number Diff line number Diff line change
@@ -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<typeof serializeFinding>;

export type ScanJsonInput = {
projectPath: string;
scanInput: ScanInput;
findings: Finding[];
suggestedFixCommands: SuggestedFixCommandPlan | null;
coverage: string[];
completeness: ScanCompleteness;
overrideFindings?: ReadonlyArray<OverrideFinding>;
maintenanceFindings?: ReadonlyArray<MaintenanceFinding>;
licenseFindings?: ReadonlyArray<LicenseFinding>;
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<typeof serializeLicenseFindings>;
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<ScanInput, "warnings">;
};

export type MultiFolderScanJsonPayload = {
multiFolder: true;
projectPath: string;
folders: string[];
findingCount: number;
status: "ok" | "partial";
complete: boolean;
diagnostics: ScanCompleteness["diagnostics"];
warnings: string[];
findings: Array<ScanJsonFinding & { subfolder: string }>;
overrideFindings?: Array<OverrideFinding & { subfolder: string }>;
maintenanceFindings?: Array<MaintenanceFinding & { subfolder: string }>;
licenseFindings?: Array<LicenseFinding & { subfolder: string }>;
scannedAt: string;
};

/**
* Multi-folder `--json` payload. HTML `report.json` reuses this so both
* artifacts share one schema.
*/
export function buildMultiFolderScanJson(params: {
results: ReadonlyArray<MultiFolderScanJsonFolder>;
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(),
};
}
39 changes: 12 additions & 27 deletions src/output/write-outputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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))}`);
}

Expand Down
44 changes: 8 additions & 36 deletions src/scan/multi-folder-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions src/scan/single-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)}`);
Expand Down
Loading
Loading