From b041befae616717fa7ff994bc0e4685278c642ea Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Tue, 1 Sep 2026 17:02:52 -0400 Subject: [PATCH 1/6] feat: add EPSS Priority Signal combining severity and exploitation likelihood Introduces a four-tier priority classification per finding by combining CVSS-derived severity with EPSS exploitation likelihood percentile. Tiers: fix_now (high impact + top-10% EPSS), fix_soon (high impact), monitor (lower severity + top-10% EPSS), low_priority (all else). Null when no EPSS data is available. Surfaces in terminal compact mode (Fix Now label), verbose table (EPSS Priority column), JSON (prioritySignal field), and HTML report (column, detail badge with raw percentile, and four-tier legend). Closes #1066 --- examples/priority-signal/package-lock.json | 154 +++++++++++++++++++++ src/output/formatters.ts | 3 + src/output/html-reporter.ts | 92 ++++++++++-- src/output/multi-folder-html-reporter.ts | 9 +- src/output/printers.ts | 33 ++++- src/utils/priority-signal.ts | 48 +++++++ 6 files changed, 321 insertions(+), 18 deletions(-) create mode 100644 examples/priority-signal/package-lock.json create mode 100644 src/utils/priority-signal.ts diff --git a/examples/priority-signal/package-lock.json b/examples/priority-signal/package-lock.json new file mode 100644 index 00000000..a4ca9c95 --- /dev/null +++ b/examples/priority-signal/package-lock.json @@ -0,0 +1,154 @@ +{ + "name": "cve-lite-example-priority-signal", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cve-lite-example-priority-signal", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "handlebars": "4.0.11", + "lodash": "4.17.20", + "mkdirp": "0.5.5", + "vm2": "3.9.17" + } + }, + "node_modules/handlebars": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", + "license": "MIT", + "dependencies": { + "async": "^1.4.0", + "optimist": "^0.6.1", + "source-map": "^0.4.4", + "uglify-js": "^2.6" + }, + "engines": { + "node": ">=0.4.7" + } + }, + "node_modules/async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "license": "MIT" + }, + "node_modules/optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "license": "MIT/X11", + "dependencies": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + } + }, + "node_modules/wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "license": "BSD-3-Clause", + "dependencies": { + "amdefine": ">=0.0.4" + } + }, + "node_modules/amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "license": "BSD-3-Clause OR MIT" + }, + "node_modules/uglify-js": { + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", + "license": "BSD-2-Clause", + "dependencies": { + "source-map": "~0.5.1", + "uglify-to-regexp": "~0.0.1", + "yargs": "~3.10.0" + } + }, + "node_modules/uglify-to-regexp": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/uglify-to-regexp/-/uglify-to-regexp-0.0.1.tgz", + "license": "MIT" + }, + "node_modules/yargs": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "license": "MIT", + "dependencies": { + "camelcase": "^1.0.1", + "cliui": "^2.1.0", + "decamelize": "^1.0.0", + "window-size": "0.1.0" + } + }, + "node_modules/camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "license": "ISC" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "license": "MIT" + }, + "node_modules/window-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "license": "MIT" + }, + "node_modules/mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + } + }, + "node_modules/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "license": "MIT" + }, + "node_modules/vm2": { + "version": "3.9.17", + "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.17.tgz", + "license": "MIT", + "dependencies": { + "acorn": "^8.7.0", + "acorn-walk": "^8.2.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/acorn": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "license": "MIT" + }, + "node_modules/acorn-walk": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + } + } + } +} diff --git a/src/output/formatters.ts b/src/output/formatters.ts index 17433d30..3c6f26b2 100644 --- a/src/output/formatters.ts +++ b/src/output/formatters.ts @@ -15,6 +15,7 @@ import { getPrimaryParent, getRootDependencies } from "../utils/finding.js"; import { calculatePathCoverage, formatDependencyPath } from "../utils/path-coverage.js"; import { pluralize } from "../utils/string.js"; import { hasMaliciousAdvisory } from "../utils/vuln.js"; +import { computePrioritySignal } from "../utils/priority-signal.js"; export function formatRelationshipLabel(value: string): string { if (value.startsWith("direct")) return chalk.green(value); @@ -304,6 +305,7 @@ export function serializeFinding(finding: Finding, plan?: SuggestedFixCommandPla recommendedNpmTransitiveRemediation: finding.recommendedNpmTransitiveRemediation ?? null, cves: finding.cveAliases, epssScores: finding.epssScores ?? null, + prioritySignal: computePrioritySignal(finding.severity, finding.epssScores) ?? null, dependencyPaths: finding.dependencyPaths, usage: finding.usage ?? null, maliciousUnverifiable: finding.maliciousUnverifiable ?? false, @@ -315,6 +317,7 @@ export function serializeFinding(finding: Finding, plan?: SuggestedFixCommandPla aliases: v.aliases ?? [], summary: v.summary ?? "", severity: inferSeverity(v), + cvssScore: (v.severity ?? []).find(s => s.type?.startsWith("CVSS"))?.score ?? null, })), }; } diff --git a/src/output/html-reporter.ts b/src/output/html-reporter.ts index 2fdbeb83..4b026299 100644 --- a/src/output/html-reporter.ts +++ b/src/output/html-reporter.ts @@ -11,6 +11,7 @@ import { renderOverrideFindingsHtml } from "./override-findings-html.js"; import { renderMaintenanceFindingsHtml } from "./maintenance-findings-html.js"; import { renderLicenseFindingsHtml } from "./license-findings-html.js"; import type { EpssScore, Finding, ScanCompleteness } from "../types.js"; +import { PRIORITY_SIGNAL_LABELS, PRIORITY_SIGNAL_DESCRIPTIONS, PRIORITY_SIGNAL_LEGEND_DESCRIPTIONS, type PrioritySignal } from "../utils/priority-signal.js"; import type { OverrideFinding } from "../overrides/types.js"; import type { MaintenanceFinding } from "../maintenance/types.js"; import type { LicenseFinding } from "../licenses/types.js"; @@ -259,7 +260,19 @@ button.header-link:hover{color:var(--link);border-color:var(--link)} .maintenance-risk table tbody tr{border-bottom:1px solid var(--border-sub)} .maintenance-risk table tbody tr:last-child{border-bottom:none} .maintenance-risk table tbody tr:hover{background:var(--surface)} -.maintenance-risk table tbody td{padding:11px 14px;font-size:13px;color:var(--text);vertical-align:middle}`; +.maintenance-risk table tbody td{padding:11px 14px;font-size:13px;color:var(--text);vertical-align:middle} +.priority-badge{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:2px 7px;border-radius:4px;white-space:nowrap} +.priority-badge.fix-now{background:#f8514922;color:#f85149;border:1px solid #f8514966} +.priority-badge.fix-soon{background:#fb850022;color:#fb8500;border:1px solid #fb850066} +.priority-badge.monitor{background:#388bfd22;color:#388bfd;border:1px solid #388bfd66} +.priority-badge.low-priority{background:#8b949e22;color:#8b949e;border:1px solid #8b949e66} +.priority-none{font-size:11px;color:var(--text-3)} +.epss-val{font-size:11px;color:var(--text-2);font-family:monospace;white-space:nowrap;cursor:help;border-bottom:1px dotted var(--text-3)} +.epss-val.hot{color:#a371f7;border-bottom-color:#a371f7} +.priority-legend{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px 18px;margin:0 32px 24px} +.priority-legend h4{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:var(--text-2);margin-bottom:12px} +.priority-legend-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px} +.priority-legend-item{display:flex;align-items:flex-start;gap:10px;font-size:12px;color:var(--text-2);line-height:1.5}`; export function renderHtmlReport(data: ReportData): string { @@ -377,6 +390,8 @@ ${duplicatePackagesHtml} Package Fix available Severity + EPSS + EPSS Priority Type Root CVE / Advisory @@ -384,9 +399,10 @@ ${duplicatePackagesHtml} ${findingRowsHtml} - No findings match your search. + No findings match your search. +${renderPriorityLegendHtml(data.findings)} ${renderOverrideFindingsHtml(data.overrideFindings, data.projectPath)} ${renderMaintenanceFindingsHtml(data.maintenanceFindings)} @@ -549,14 +565,64 @@ function renderRelBadge(finding: SerializedFinding): string { return `${escapeHtml(finding.relationship)}`; } -function renderEpssHtml(epssScores: EpssScore[] | null | undefined): string { - if (!epssScores || epssScores.length === 0) return ""; - const best = epssScores.slice().sort((a, b) => b.epss - a.epss)[0]!; - const pct = (best.epss * 100).toFixed(1); +function renderPrioritySignalDetailHtml( + priority: PrioritySignal | null, + epssScores: EpssScore[] | null | undefined, +): string { + if (!priority) return ""; + const label = PRIORITY_SIGNAL_LABELS[priority]; + const desc = PRIORITY_SIGNAL_DESCRIPTIONS[priority]; + const cssClass = priority.replace(/_/g, "-"); + let epssLine = ""; + if (epssScores && epssScores.length > 0) { + const best = epssScores.slice().sort((a, b) => b.percentile - a.percentile)[0]!; + const top = (1 - best.percentile) * 100; + const topStr = top < 0.01 ? "<0.01" : top < 1 ? top.toFixed(2) : top.toFixed(1); + epssLine = `

${escapeHtml(best.cve)} - top ${topStr}% exploitation likelihood (EPSS: ${(best.epss * 100).toFixed(2)}%)

`; + } + return `

EPSS Priority Signal

+

${escapeHtml(label)}  ${escapeHtml(desc)}

+ ${epssLine}`; +} + +export function renderPriorityLegendHtml(findings: SerializedFinding[]): string { + const hasEpss = findings.some(f => f.epssScores && f.epssScores.length > 0); + if (!hasEpss) return ""; + const items: Array<{ signal: PrioritySignal; label: string; desc: string }> = [ + { signal: "fix_now", label: PRIORITY_SIGNAL_LABELS.fix_now, desc: PRIORITY_SIGNAL_LEGEND_DESCRIPTIONS.fix_now }, + { signal: "fix_soon", label: PRIORITY_SIGNAL_LABELS.fix_soon, desc: PRIORITY_SIGNAL_LEGEND_DESCRIPTIONS.fix_soon }, + { signal: "monitor", label: PRIORITY_SIGNAL_LABELS.monitor, desc: PRIORITY_SIGNAL_LEGEND_DESCRIPTIONS.monitor }, + { signal: "low_priority", label: PRIORITY_SIGNAL_LABELS.low_priority, desc: PRIORITY_SIGNAL_LEGEND_DESCRIPTIONS.low_priority }, + ]; + const itemsHtml = items.map(({ signal, label, desc }) => { + const cssClass = signal.replace(/_/g, "-"); + return `
${escapeHtml(label)}${escapeHtml(desc)}
`; + }).join("\n"); + return `
+

Priority Signal - severity x exploitation likelihood

+
+${itemsHtml} +
+

EPSS = Exploit Prediction Scoring System (FIRST.org). "top X%" = this CVE is among the X% most likely to be exploited across all published CVEs. "-" is shown in the EPSS and EPSS Priority columns when EPSS data is unavailable for a finding.

+
`; +} + +function renderEpssColumnHtml(epssScores: EpssScore[] | null | undefined): string { + if (!epssScores || epssScores.length === 0) return `-`; + const best = epssScores.slice().sort((a, b) => b.percentile - a.percentile)[0]!; const top = (1 - best.percentile) * 100; const topStr = top < 0.01 ? "<0.01" : top < 1 ? top.toFixed(2) : top.toFixed(1); - return `

EPSS

-

${escapeHtml(pct)}% exploitation probability - ${escapeHtml(best.cve)} (top ${topStr}% of all CVEs)

`; + const isHot = best.percentile >= 0.90; + const hotClass = isHot ? " hot" : ""; + const tooltip = `This CVE is more likely to be exploited than ${(best.percentile * 100).toFixed(0)}% of all published CVEs (EPSS score: ${(best.epss * 100).toFixed(2)}%)`; + return `top ${topStr}%`; +} + +function renderPriorityColumnHtml(priority: PrioritySignal | null): string { + if (!priority) return `-`; + const label = PRIORITY_SIGNAL_LABELS[priority]; + const cssClass = priority.replace(/_/g, "-"); + return `${escapeHtml(label)}`; } export function renderFindingRow(finding: SerializedFinding, idx: number, skippedKeys?: ReadonlySet): string { @@ -604,17 +670,23 @@ export function renderFindingRow(finding: SerializedFinding, idx: number, skippe const rootDepsHtml = finding.rootDependencies.length > 0 ? finding.rootDependencies.map(name => `${escapeHtml(name)}`).join(", ") : `-`; + + const epssHtml = renderEpssColumnHtml(finding.epssScores); + const priorityHtml = renderPriorityColumnHtml(finding.prioritySignal); + return `
${escapeHtml(finding.version)}
${fixHtml} ${escapeHtml(finding.severity)} + ${epssHtml} + ${priorityHtml} ${renderRelBadge(finding)} ${rootDepsHtml} ${cveLinks} - +

Description

@@ -632,7 +704,7 @@ export function renderFindingRow(finding: SerializedFinding, idx: number, skippe

${escapeHtml(finding.riskSummary)}

Next action

${escapeHtml(finding.nextAction)}

- ${renderEpssHtml(finding.epssScores)} + ${renderPrioritySignalDetailHtml(finding.prioritySignal, finding.epssScores)}
diff --git a/src/output/multi-folder-html-reporter.ts b/src/output/multi-folder-html-reporter.ts index 72cc3ff4..f8559655 100644 --- a/src/output/multi-folder-html-reporter.ts +++ b/src/output/multi-folder-html-reporter.ts @@ -10,6 +10,7 @@ import { renderFixPlan, renderCompletenessBanner, serializeHtmlFinding, + renderPriorityLegendHtml, } from "./html-reporter.js"; import type { SerializedFinding } from "./html-reporter.js"; import { renderOverrideFindingsHtml } from "./override-findings-html.js"; @@ -134,7 +135,7 @@ function renderFolderSection( : ""; const emptyRow = result.sorted.length === 0 - ? `No findings` + ? `No findings` : ""; return ` @@ -170,6 +171,8 @@ function renderFolderSection( Package Fix available Severity + EPSS + EPSS Priority Type Root CVE / Advisory @@ -177,7 +180,7 @@ function renderFolderSection( ${emptyRow}${findingRowsHtml} - No findings match your search. + No findings match your search. @@ -334,6 +337,8 @@ ${aggregateBannerHtml} ${folderSectionsHtml} +${renderPriorityLegendHtml(reportData.findings)} +