diff --git a/src/output/html-reporter.ts b/src/output/html-reporter.ts index c89daa3..d782ca6 100644 --- a/src/output/html-reporter.ts +++ b/src/output/html-reporter.ts @@ -1,7 +1,7 @@ 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"; @@ -9,7 +9,18 @@ import { pluralize } from "../utils/string.js"; import type { Finding } from "../types.js"; import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; -export type SerializedFinding = ReturnType; +export type SerializedFinding = ReturnType; + +export function serializeHtmlFinding( + finding: Finding, + plan?: SuggestedFixCommandPlan | null, +) { + return { + ...serializeFinding(finding, plan), + riskSummary: summarizeRisk(finding), + nextAction: summarizeNextAction(finding), + }; +} export type ReportData = { projectPath: string; @@ -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, @@ -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} @@ -502,6 +516,10 @@ export function renderFindingRow(finding: SerializedFinding, idx: number, skippe

Recommended action

${recommendedActionHtml} +

Risk summary

+

${escapeHtml(finding.riskSummary)}

+

Next action

+

${escapeHtml(finding.nextAction)}

diff --git a/src/output/multi-folder-html-reporter.ts b/src/output/multi-folder-html-reporter.ts index 6010116..e576c31 100644 --- a/src/output/multi-folder-html-reporter.ts +++ b/src/output/multi-folder-html-reporter.ts @@ -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"; @@ -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) diff --git a/tests/html-reporter.test.ts b/tests/html-reporter.test.ts index b976086..f5a4899 100644 --- a/tests/html-reporter.test.ts +++ b/tests/html-reporter.test.ts @@ -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("

Recommended action

"); + const riskSummaryIdx = html.indexOf( + "

Risk summary

", + ); + const nextActionIdx = html.indexOf( + "

Next action

", + ); + + 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: "", version: "1.0.0", ecosystem: "npm" }, + firstFixedVersion: null, + }); + + const html = renderHtmlReport( + buildReportData({ ...BASE_PARAMS, findings: [finding] }), + ); + + expect(html).toContain( + "No known fix exists for <unsafe-package>. Consider replacing it", + ); + expect(html).not.toContain( + "No known fix exists for . Consider replacing it", + ); + }); + it("embeds reportData as an inline script", () => { const html = renderHtmlReport(data); expect(html).toContain("const reportData ="); diff --git a/tests/output.test.ts b/tests/output.test.ts index b29418d..3c10e5a 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -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", () => { diff --git a/website/docs/html-report.md b/website/docs/html-report.md index 343c524..ca669f4 100644 --- a/website/docs/html-report.md +++ b/website/docs/html-report.md @@ -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