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: 21 additions & 3 deletions src/output/html-reporter.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { serializeFinding } from "./formatters.js";
import { serializeFinding, summarizeNextAction, summarizeRisk } from "./formatters.js";
import { LOGO_BASE64 } from "./logo-base64.js";
import { OWASP_LOGO_BASE64 } from "./owasp-logo-base64.js";
import { isMajorVersionBump } from "../utils/version.js";
import { pluralize } from "../utils/string.js";
import type { Finding } from "../types.js";
import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js";

export type SerializedFinding = ReturnType<typeof serializeFinding>;
export type SerializedFinding = ReturnType<typeof serializeHtmlFinding>;

export function serializeHtmlFinding(
finding: Finding,
plan?: SuggestedFixCommandPlan | null,
) {
return {
...serializeFinding(finding, plan),
riskSummary: summarizeRisk(finding),
nextAction: summarizeNextAction(finding),
};
}

export type ReportData = {
projectPath: string;
Expand Down Expand Up @@ -42,7 +53,9 @@ export function buildReportData(params: {
packageManager: params.packageManager,
lockfileSource: params.lockfileSource,
packageCount: params.packageCount,
findings: params.findings.map(finding => serializeFinding(finding, params.suggestedFixCommands)),
findings: params.findings.map(finding =>
serializeHtmlFinding(finding, params.suggestedFixCommands),
),
suggestedFixCommands: params.suggestedFixCommands,
notes: params.notes,
warnings: params.warnings,
Expand Down Expand Up @@ -144,6 +157,7 @@ button.header-link:hover{color:#58a6ff;border-color:#58a6ff}
.expanded-inner{padding:16px 20px 20px 48px;display:flex;gap:32px}
.detail-col{flex:1}
.detail-col h4{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:#8b949e;margin-bottom:8px}
.detail-col h4.detail-subheading{margin-top:14px}
.detail-col p{font-size:12px;color:#8b949e;line-height:1.6}
.dep-path{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.dep-node{font-size:11px;font-family:monospace;background:#161b22;border:1px solid #30363d;border-radius:4px;padding:2px 8px;color:#e6edf3}
Expand Down Expand Up @@ -502,6 +516,10 @@ export function renderFindingRow(finding: SerializedFinding, idx: number, skippe
<div class="detail-col">
<h4>Recommended action</h4>
${recommendedActionHtml}
<h4 class="detail-subheading">Risk summary</h4>
<p>${escapeHtml(finding.riskSummary)}</p>
<h4 class="detail-subheading">Next action</h4>
<p>${escapeHtml(finding.nextAction)}</p>
</div>
</div>
</td>
Expand Down
12 changes: 9 additions & 3 deletions src/output/multi-folder-html-reporter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import fs from "node:fs";
import path from "node:path";
import type { MultiFolderScanResult } from "../scan/multi-folder-scan.js";
import { REPORT_STYLES, escapeHtml, renderFindingRow, renderFixPlan, openInBrowser } from "./html-reporter.js";
import {
REPORT_STYLES,
escapeHtml,
openInBrowser,
renderFindingRow,
renderFixPlan,
serializeHtmlFinding,
} from "./html-reporter.js";
import type { SerializedFinding } from "./html-reporter.js";
import { LOGO_BASE64 } from "./logo-base64.js";
import { OWASP_LOGO_BASE64 } from "./owasp-logo-base64.js";
import { serializeFinding } from "./formatters.js";
import { pluralize } from "../utils/string.js";
import type { SeverityLabel } from "../types.js";

Expand Down Expand Up @@ -124,7 +130,7 @@ export async function writeMultiFolderHtmlReport(params: {

// Pre-serialize findings once per folder for both HTML rendering and JS filter/sort
const allSerialized: SerializedFinding[][] = params.results.map(r =>
r.sorted.map(f => serializeFinding(f, r.suggestedFixCommands)),
r.sorted.map(f => serializeHtmlFinding(f, r.suggestedFixCommands)),
);

// Compact per-folder findings for JavaScript (only fields needed for filter/sort/search)
Expand Down
79 changes: 79 additions & 0 deletions tests/html-reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,85 @@ describe("renderHtmlReport", () => {
expect(renderHtmlReport(data)).toContain("1.8.0");
});

it("shows contextual risk and next-action guidance for direct findings", () => {
const html = renderHtmlReport(data);
const recommendedActionIdx = html.indexOf("<h4>Recommended action</h4>");
const riskSummaryIdx = html.indexOf(
"<h4 class=\"detail-subheading\">Risk summary</h4>",
);
const nextActionIdx = html.indexOf(
"<h4 class=\"detail-subheading\">Next action</h4>",
);

expect(recommendedActionIdx).toBeGreaterThan(-1);
expect(riskSummaryIdx).toBeGreaterThan(recommendedActionIdx);
expect(nextActionIdx).toBeGreaterThan(riskSummaryIdx);
expect(html).toContain(
"Critical direct dependency. Prioritize this first because the project controls it directly.",
);
expect(html).toContain("Upgrade lodash toward 4.17.21.");
});

it("shows parent-specific guidance for transitive findings", () => {
const finding = makeFinding({
pkg: { name: "qs", version: "6.5.2", ecosystem: "npm" },
relationship: "transitive",
dependencyPaths: [["project", "express", "qs"]],
firstFixedVersion: "6.11.0",
recommendedNpmTransitiveRemediation: {
kind: "update-parent-within-range",
package: "express",
currentVersion: "4.17.1",
viaPath: ["project", "express"],
reason: "Safe child version available within current range",
targetChildVersion: "6.11.0",
},
});

const html = renderHtmlReport(
buildReportData({ ...BASE_PARAMS, findings: [finding] }),
);

expect(html).toContain(
"The current parent range can already absorb a safe qs update via express.",
);
expect(html).toContain("Lockfile refresh");
expect(html).toContain("express already permits a safe version.");
});

it("shows removal guidance for malicious direct packages", () => {
const finding = makeFinding({
firstFixedVersion: null,
vulnerabilities: [makeVuln({ id: "MAL-2025-21003" })],
});

const html = renderHtmlReport(
buildReportData({ ...BASE_PARAMS, findings: [finding] }),
);

expect(html).toContain(
"This package has a malicious code advisory. Remove it from your dependencies.",
);
});

it("escapes generated guidance before rendering", () => {
const finding = makeFinding({
pkg: { name: "<unsafe-package>", version: "1.0.0", ecosystem: "npm" },
firstFixedVersion: null,
});

const html = renderHtmlReport(
buildReportData({ ...BASE_PARAMS, findings: [finding] }),
);

expect(html).toContain(
"No known fix exists for &lt;unsafe-package&gt;. Consider replacing it",
);
expect(html).not.toContain(
"No known fix exists for <unsafe-package>. Consider replacing it",
);
});

it("embeds reportData as an inline script", () => {
const html = renderHtmlReport(data);
expect(html).toContain("const reportData =");
Expand Down
2 changes: 2 additions & 0 deletions tests/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ describe("output formatters", () => {
id: "OSV-123",
severity: "critical",
});
expect(serialized).not.toHaveProperty("riskSummary");
expect(serialized).not.toHaveProperty("nextAction");
});

it("includes dev:true in serialized finding when pkg.dev is true", () => {
Expand Down
3 changes: 2 additions & 1 deletion website/docs/html-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ Running `--report` to the same directory a second time overwrites both files.

**Findings table** with interactive controls:
- Filter by severity or direct-only
- Expandable rows showing vulnerability description, dependency path, and recommended action
- Expandable rows showing vulnerability description, contextual risk summary,
next action, dependency path, and recommended action
- CVE / GHSA advisory IDs linked to osv.dev and GitHub Security Advisories
- Fix version shown inline when one is available

Expand Down