fad-checker 2.2.3 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cve-report.js CHANGED
@@ -14,6 +14,7 @@ const { getCodec, allCodecs, ORDER } = require("./codecs");
14
14
  const { computePriority, sortByPriority } = require("./priority");
15
15
  const { buildInventory } = require("./unmanaged");
16
16
  const { buildEmbeddedInventory } = require("./embedded");
17
+ const { renderCharts } = require("./charts");
17
18
 
18
19
  // CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
19
20
  // back to the raw id (with a "—" placeholder name where the row asks for one).
@@ -98,10 +99,13 @@ h1 { margin: 0 0 4px 0; font-size: 28px; letter-spacing: -.5px; }
98
99
  .report-subtitle { color: #6b7280; font-size: 14px; font-weight: 500; margin-bottom: 12px; letter-spacing: .3px; }
99
100
  h2 { margin: 32px 0 12px; font-size: 20px; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
100
101
  .meta { color: #6b7280; font-size: 13px; margin-bottom: 16px; }
101
- .summary { display: flex; flex-wrap: wrap; gap: 12px; margin: 16px 0; }
102
- .summary .card { padding: 10px 16px; border-radius: 6px; background: #fff; border: 1px solid #e5e7eb; min-width: 120px; }
103
- .summary .card .v { font-size: 24px; font-weight: 700; }
104
- .summary .card .l { font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: .5px; }
102
+ /* Totals row compact cards that FLEX-GROW to spread evenly across the full
103
+ render width (flex:1), so the set fills the line above the overview charts
104
+ instead of clustering on the left. */
105
+ .summary { display: flex; flex-wrap: wrap; gap: 7px; margin: 14px 0; }
106
+ .summary .card { flex: 1 1 0; padding: 6px 11px; border-radius: 5px; background: #fff; border: 1px solid #e5e7eb; min-width: 100px; }
107
+ .summary .card .v { font-size: 20px; font-weight: 700; line-height: 1.1; }
108
+ .summary .card .l { font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: .3px; white-space: nowrap; }
105
109
  .summary .card.sev-critical { border-left: 4px solid #dc2626; }
106
110
  .summary .card.sev-critical .v { color: #dc2626; }
107
111
  .summary .card.sev-high { border-left: 4px solid #f97316; }
@@ -112,13 +116,25 @@ h2 { margin: 32px 0 12px; font-size: 20px; border-bottom: 2px solid #e5e7eb; pad
112
116
  .summary .card.sev-low .v { color: #2563eb; }
113
117
  .summary .card.sev-vendored { border-left: 4px solid #6366f1; }
114
118
  .summary .card.sev-warn { border-left: 4px solid #f59e0b; background: #fffbeb; }
119
+ /* Overview charts — four equal-width (25%) cards on one row, stretched to the
120
+ same height. All four donuts share one viewBox width (see lib/charts.js) so
121
+ they scale identically and stay the SAME size whatever the legend length.
122
+ The copy button overlays the top-right. */
123
+ .charts-row { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0 18px; align-items: stretch; }
124
+ .charts-row .chart-card { flex: 1 1 0; min-width: 200px; position: relative; margin: 0; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; }
125
+ .charts-row .chart-copy { position: absolute; top: 6px; right: 8px; padding: 2px 7px; font-size: 11px; z-index: 1; }
126
+ .charts-row .chart-svg { display: block; width: 100%; height: auto; }
127
+ .charts-row .chart-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
128
+ .charts-row .chart-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; color: #4b5563; }
129
+ .charts-row .chart-empty { color: #9ca3af; font-style: italic; font-size: 11px; padding: 14px 0; }
115
130
  .toc { position: sticky; top: 0; z-index: 50; background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 12px; margin: 16px 0; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.04); }
116
131
  .toc a { color: #4338ca; text-decoration: none; padding: 2px 6px; border-radius: 3px; }
117
132
  .toc a:hover { background: #eef2ff; }
118
133
  .exec-summary .exec-top { margin-top: 8px; padding-top: 10px; border-top: 1px dashed #e5e7eb; }
119
134
  .exec-summary .exec-top-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: #6b7280; margin-bottom: 6px; }
120
135
  .exec-summary .exec-top-list { list-style: none; padding: 0; margin: 0; font-size: 12px; }
121
- .exec-summary .exec-top-list li { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-family: ui-monospace, monospace; }
136
+ .exec-summary .exec-top-list li { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 3px 0; font-family: ui-monospace, monospace; }
137
+ .exec-summary .exec-cwes-chips { display: inline-flex; flex-wrap: wrap; gap: 4px; }
122
138
  .exec-summary .exec-cve-link { color: #1d4ed8; text-decoration: none; font-weight: 600; }
123
139
  .exec-summary .exec-cve-link:hover { text-decoration: underline; }
124
140
  .exec-summary .exec-ver { color: #6b7280; }
@@ -153,6 +169,17 @@ td.cwe .cwe-name { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; f
153
169
  .exec-summary .exec-cwe { display: inline-flex; align-items: baseline; gap: 4px; font-size: 11px; color: #4b5563; background: #eef2ff; border: 1px solid #c7d2fe; padding: 1px 6px; border-radius: 3px; margin-left: 2px; }
154
170
  .exec-summary .exec-cwe .exec-cwe-id { font-family: ui-monospace, monospace; font-weight: 600; color: #4338ca; }
155
171
  .exec-summary .exec-cwe .exec-cwe-name { color: #4b5563; }
172
+ .exec-summary .exec-cwe .exec-cwe-count { font-family: ui-monospace, monospace; font-size: 10px; color: #6366f1; font-weight: 700; }
173
+ /* transitive-fallback tag on a top-3 row */
174
+ .exec-summary .exec-scope-tag { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: #92400e; background: #fef3c7; border: 1px solid #fde68a; padding: 1px 5px; border-radius: 3px; }
175
+ /* top-EOL block */
176
+ .exec-summary .exec-eol { margin-top: 8px; padding-top: 10px; border-top: 1px dashed #e5e7eb; }
177
+ .exec-summary .exec-eol-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: #6b7280; margin-bottom: 6px; }
178
+ .exec-summary .exec-eol-list { list-style: none; padding: 0; margin: 0; font-size: 12px; }
179
+ .exec-summary .exec-eol-list li { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-family: ui-monospace, monospace; }
180
+ /* short summary of everything else */
181
+ .exec-summary .exec-rest { margin-top: 8px; padding-top: 10px; border-top: 1px dashed #e5e7eb; font-size: 12px; color: #374151; line-height: 1.6; }
182
+ .exec-summary .exec-rest .exec-rest-label { font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: #6b7280; font-size: 11px; margin-right: 4px; }
156
183
  td.published { font-family: ui-monospace, monospace; font-size: 11px; color: #6b7280; white-space: nowrap; }
157
184
  tr.cve-row.cpe-fp td { opacity: .55; }
158
185
  .jump-badge { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
@@ -1194,21 +1221,19 @@ function renderRetireTable(matches) {
1194
1221
  }
1195
1222
 
1196
1223
  /**
1197
- * One "vulnerable to" clause for a match, in the format requested for the
1198
- * Word-pasteable summary: prefer the primary CWE ("CWE-79 : Cross-site
1199
- * Scripting"), fall back to the CVE id when no weakness is recorded.
1224
+ * The "vulnerable to" clause for a match in the Word-pasteable summary: ALL of
1225
+ * the finding's CWEs ("CWE-79 : Cross-site Scripting, CWE-89 : SQL Injection"),
1226
+ * falling back to the CVE id when no weakness is recorded.
1200
1227
  */
1201
1228
  function execWeakness(m) {
1202
1229
  const cwes = Array.isArray(m.cve.cwes) ? m.cve.cwes : [];
1203
- const primary = cwes.find(c => cweName(c)) || cwes[0];
1204
- if (primary) {
1205
- const label = cweName(primary);
1206
- return label ? `${primary} : ${label}` : primary;
1230
+ if (cwes.length) {
1231
+ return cwes.map(c => { const label = cweName(c); return label ? `${c} : ${label}` : c; }).join(", ");
1207
1232
  }
1208
1233
  return m.cve.id || "an unspecified weakness";
1209
1234
  }
1210
1235
 
1211
- function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats, eolResults, obsoleteResults, outdatedResults, warnings, totalDeps, topCriticalMatches, interactive = false }) {
1236
+ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats, eolResults, obsoleteResults, outdatedResults, warnings, totalDeps, topCriticalMatches, nativeBinaryCount = 0, interactive = false }) {
1212
1237
  const eolN = eolResults?.length || 0;
1213
1238
  const obsN = obsoleteResults?.length || 0;
1214
1239
  const outN = outdatedResults?.length || 0;
@@ -1222,77 +1247,102 @@ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats,
1222
1247
  else if (prodStats.low > 0 || retireStats.low > 0 || outN > 0) level = "LOW";
1223
1248
  else if (prodStats.total > 0) level = "UNKNOWN";
1224
1249
 
1225
- const bullets = [];
1226
- if (prodStats.total) bullets.push(`<strong>${prodStats.total}</strong> CVE in production deps (critical=${prodStats.critical}, high=${prodStats.high}, medium=${prodStats.medium}, low=${prodStats.low})`);
1227
- if (devStats.total) bullets.push(`<strong>${devStats.total}</strong> CVE in dev/test deps`);
1228
- if (retireStats.total) bullets.push(`<strong>${retireStats.total}</strong> vulnerable vendored JS finding(s) (retire.js)`);
1229
- if (eolN) bullets.push(`<strong>${eolN}</strong> end-of-life framework${eolN > 1 ? "s" : ""}`);
1230
- if (obsN) bullets.push(`<strong>${obsN}</strong> obsolete / deprecated lib${obsN > 1 ? "s" : ""}`);
1231
- if (outN) bullets.push(`<strong>${outN}</strong> outdated lib${outN > 1 ? "s" : ""}`);
1232
- if (warnings?.length) bullets.push(`<strong>${warnings.length}</strong> scan-completeness alert${warnings.length > 1 ? "s" : ""} — see chapter 0`);
1233
-
1234
- const topPreview = (topCriticalMatches || []).length ? `<div class="exec-top">
1235
- <div class="exec-top-title">Top ${topCriticalMatches.length} most critical (preview)</div>
1236
- <ul class="exec-top-list">${topCriticalMatches.map(m => {
1237
- // First known-CWE on the match gets surfaced in the summary so the
1238
- // reader has a one-line answer to "what kind of weakness is this?"
1250
+ const top = topCriticalMatches || [];
1251
+ const topEol = pickTopEol(eolResults, 2);
1252
+
1253
+ const cweChipHtml = (id, name) => `<span class="exec-cwe" title="${esc(id)}${name ? ": " + esc(name) : ""}"><span class="exec-cwe-id">${esc(id)}</span>${name ? `<span class="exec-cwe-name">${esc(name)}</span>` : ""}</span>`;
1254
+
1255
+ // ---- Block 1: the 3 most critical (direct prod, transitive fallback) ----
1256
+ // Each row lists ALL of its CWEs right beside it (the weakness fingerprint
1257
+ // of that specific finding) NOT a separate report-wide CWE aggregate.
1258
+ const topBlock = top.length ? `<div class="exec-top">
1259
+ <div class="exec-top-title">Top ${top.length} most critical · direct production dependencies</div>
1260
+ <ul class="exec-top-list">${top.map(m => {
1239
1261
  const cwes = Array.isArray(m.cve.cwes) ? m.cve.cwes : [];
1240
- const primary = cwes.find(c => cweName(c)) || cwes[0];
1241
- const primaryName = primary ? cweName(primary) : "";
1242
- const cweChip = primary ? `<span class="exec-cwe" title="${esc(primary)}${primaryName ? ": " + esc(primaryName) : ""}"><span class="exec-cwe-id">${esc(primary)}</span>${primaryName ? `<span class="exec-cwe-name">${esc(primaryName)}</span>` : ""}</span>` : "";
1262
+ const cweChips = cwes.length ? `<span class="exec-cwes-chips">${cwes.map(c => cweChipHtml(c, cweName(c))).join("")}</span>` : "";
1263
+ const transitiveTag = m.dep?.scope === "transitive"
1264
+ ? `<span class="exec-scope-tag" title="Pulled in transitively — fewer than ${top.length} direct production findings, so the worst transitive ones fill the rest">transitive</span>` : "";
1243
1265
  return `<li>
1244
1266
  ${badge((m.cve.severity || "HIGH").toUpperCase())}
1245
1267
  <a class="exec-cve-link" href="#ch${m.dep?.scope === "vendored" ? "2" : "1"}">${esc(m.cve.id)}</a>
1246
1268
  <code>${esc(depDisplayName(m.dep))}</code>
1247
1269
  <span class="exec-ver">${esc(m.dep.version || "?")}</span>
1248
1270
  ${m.cve.fixVersion ? `<span class="exec-fix">→ ${esc(m.cve.fixVersion)}</span>` : ""}
1249
- ${cweChip}
1271
+ ${cweChips}
1272
+ ${transitiveTag}
1250
1273
  </li>`;
1251
1274
  }).join("")}</ul>
1252
1275
  </div>` : "";
1253
1276
 
1277
+ // ---- Block 2: the 2 most-overdue end-of-life frameworks ----
1278
+ const eolWhen = e => (e.eol && e.eol !== "true" ? `EOL since ${esc(e.eol)}` : "end-of-life");
1279
+ const eolBlock = topEol.length ? `<div class="exec-eol">
1280
+ <div class="exec-eol-title">${topEol.length === 1 ? "Top end-of-life framework" : `Top ${topEol.length} end-of-life frameworks`}</div>
1281
+ <ul class="exec-eol-list">${topEol.map(e => `<li>
1282
+ <a class="exec-cve-link" href="#ch4">${esc(e.product || e.productSlug || "")}${e.cycle ? " " + esc(e.cycle) : ""}</a>
1283
+ <code>${esc(depDisplayName(e.dep))}</code>
1284
+ <span class="exec-ver">${eolWhen(e)}</span>
1285
+ ${e.latest ? `<span class="exec-fix">→ latest ${esc(e.latest)}</span>` : ""}
1286
+ </li>`).join("")}</ul>
1287
+ </div>` : "";
1288
+
1289
+ // ---- Block 3: a very short summary of everything else ----
1290
+ const restParts = [];
1291
+ if (prodStats.total) restParts.push(`<strong>${prodStats.total}</strong> production CVE (critical=${prodStats.critical}, high=${prodStats.high}, medium=${prodStats.medium}, low=${prodStats.low})`);
1292
+ if (devStats.total) restParts.push(`<strong>${devStats.total}</strong> dev/test CVE`);
1293
+ if (retireStats.total) restParts.push(`<strong>${retireStats.total}</strong> vulnerable vendored-JS`);
1294
+ if (eolN) restParts.push(`<strong>${eolN}</strong> EOL framework${eolN > 1 ? "s" : ""}`);
1295
+ if (obsN) restParts.push(`<strong>${obsN}</strong> obsolete`);
1296
+ if (outN) restParts.push(`<strong>${outN}</strong> outdated`);
1297
+ if (nativeBinaryCount) restParts.push(`<strong>${nativeBinaryCount}</strong> unmanaged native binar${nativeBinaryCount > 1 ? "ies" : "y"}`);
1298
+ if (warnings?.length) restParts.push(`<strong>${warnings.length}</strong> scan alert${warnings.length > 1 ? "s" : ""}`);
1299
+ const restBlock = `<div class="exec-rest"><span class="exec-rest-label">Everything else:</span> ${restParts.length ? restParts.join(" · ") : "no other findings"}</div>`;
1300
+
1254
1301
  // ---- Word-pasteable copy payload (built only for the interactive HTML) ----
1255
1302
  let copyUi = "";
1256
1303
  if (interactive) {
1257
1304
  const stripTags = s => s.replace(/<[^>]+>/g, "");
1258
- const plainBullets = bullets.map(stripTags);
1259
- const top = topCriticalMatches || [];
1260
1305
  const topSentences = top.map(m => ({
1261
1306
  lib: depDisplayName(m.dep),
1262
1307
  ver: m.dep.version || "?",
1263
1308
  weakness: execWeakness(m),
1264
1309
  crit: (m.cve.severity || "HIGH").toUpperCase(),
1310
+ transitive: m.dep?.scope === "transitive",
1265
1311
  }));
1312
+ const eolSentence = e => {
1313
+ const when = e.eol && e.eol !== "true" ? `end-of-life since ${e.eol}` : "end-of-life";
1314
+ return `- "${e.product || e.productSlug || ""}${e.cycle ? " " + e.cycle : ""}" (${depDisplayName(e.dep)}) is ${when}${e.latest ? `; latest is ${e.latest}` : ""}`;
1315
+ };
1316
+ const restText = stripTags(restParts.join(" · ")) || "no other findings";
1266
1317
 
1267
- // Plain-text version (text/plain) — exact bullet format requested.
1268
- const plainLines = [];
1269
- plainLines.push(`EXECUTIVE SUMMARY — ${level}`);
1270
- plainLines.push(`${totalDeps} dependencies scanned`);
1271
- plainLines.push("");
1272
- if (plainBullets.length) for (const b of plainBullets) plainLines.push(`- ${b}`);
1273
- else plainLines.push("- No findings detected.");
1318
+ // Plain-text (text/plain).
1319
+ const plainLines = [`EXECUTIVE SUMMARY — ${level}`, `${totalDeps} dependencies scanned`];
1274
1320
  if (topSentences.length) {
1275
- plainLines.push("");
1276
- plainLines.push(`Top ${topSentences.length} most critical:`);
1277
- for (const t of topSentences) {
1278
- plainLines.push(`- The library "${t.lib}" version "${t.ver}" is vulnerable to "${t.weakness}" ( ${t.crit} )`);
1279
- }
1321
+ plainLines.push("", `Top ${topSentences.length} most critical (direct production dependencies):`);
1322
+ for (const t of topSentences) plainLines.push(`- The library "${t.lib}" version "${t.ver}" is vulnerable to "${t.weakness}" ( ${t.crit} )${t.transitive ? " [transitive]" : ""}`);
1280
1323
  }
1324
+ if (topEol.length) {
1325
+ plainLines.push("", `Top ${topEol.length} end-of-life framework${topEol.length > 1 ? "s" : ""}:`);
1326
+ for (const e of topEol) plainLines.push(eolSentence(e));
1327
+ }
1328
+ plainLines.push("", `Everything else: ${restText}`);
1281
1329
  const plainText = plainLines.join("\n");
1282
1330
 
1283
- // Rich version (text/html) — inline styles so Word keeps the formatting.
1284
- const richBullets = plainBullets.length
1285
- ? `<ul style="margin:4px 0 0; padding-left:22px;">${plainBullets.map(b => `<li style="margin:2px 0;">${esc(b)}</li>`).join("")}</ul>`
1286
- : `<p style="margin:4px 0;">No findings detected.</p>`;
1331
+ // Rich (text/html) — inline styles so Word keeps the formatting.
1287
1332
  const richTop = topSentences.length
1288
- ? `<p style="font-weight:bold; margin:12px 0 4px;">Top ${topSentences.length} most critical:</p>
1333
+ ? `<p style="font-weight:bold; margin:12px 0 4px;">Top ${topSentences.length} most critical (direct production dependencies):</p>
1289
1334
  <ul style="margin:0; padding-left:22px;">${topSentences.map(t =>
1290
- `<li style="margin:3px 0;">The library "<b>${esc(t.lib)}</b>" version "<b>${esc(t.ver)}</b>" is vulnerable to "<b>${esc(t.weakness)}</b>" ( ${esc(t.crit)} )</li>`).join("")}</ul>`
1335
+ `<li style="margin:3px 0;">The library "<b>${esc(t.lib)}</b>" version "<b>${esc(t.ver)}</b>" is vulnerable to "<b>${esc(t.weakness)}</b>" ( ${esc(t.crit)} )${t.transitive ? ` <i>[transitive]</i>` : ""}</li>`).join("")}</ul>`
1336
+ : "";
1337
+ const richEol = topEol.length
1338
+ ? `<p style="font-weight:bold; margin:12px 0 4px;">Top ${topEol.length} end-of-life framework${topEol.length > 1 ? "s" : ""}:</p>
1339
+ <ul style="margin:0; padding-left:22px;">${topEol.map(e => `<li style="margin:3px 0;">${esc(stripTags(eolSentence(e)).replace(/^- /, ""))}</li>`).join("")}</ul>`
1291
1340
  : "";
1341
+ const richRest = `<p style="margin:12px 0 0;"><b>Everything else:</b> ${esc(restText)}</p>`;
1292
1342
  const richHtml = `<div style="font-family:Calibri,Arial,sans-serif; font-size:11pt; color:#1f2937;">`
1293
1343
  + `<p style="font-size:14pt; font-weight:bold; margin:0 0 2px;">Executive Summary — ${esc(level)}</p>`
1294
1344
  + `<p style="margin:0 0 8px; color:#4b5563;">${esc(totalDeps)} dependencies scanned</p>`
1295
- + richBullets + richTop + `</div>`;
1345
+ + richTop + richEol + richRest + `</div>`;
1296
1346
 
1297
1347
  copyUi = `<button class="btn-copy exec-copy-btn" type="button" title="Copy the executive summary — paste it into Word with formatting preserved">📋 Copy summary</button>`
1298
1348
  + `<template class="exec-copy-rich">${richHtml}</template>`
@@ -1306,10 +1356,9 @@ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats,
1306
1356
  ${copyUi}
1307
1357
  </div>
1308
1358
  <div class="exec-meta">${esc(totalDeps)} dependencies scanned</div>
1309
- <ul class="exec-bullets">
1310
- ${bullets.map(b => `<li>${b}</li>`).join("") || `<li>No findings detected.</li>`}
1311
- </ul>
1312
- ${topPreview}
1359
+ ${topBlock}
1360
+ ${eolBlock}
1361
+ ${restBlock}
1313
1362
  </div>`;
1314
1363
  }
1315
1364
 
@@ -1439,11 +1488,16 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1439
1488
  <span class="toolbar-label">Click a section header or a CVE row to toggle.</span>
1440
1489
  </div>`;
1441
1490
 
1491
+ // Committed native binaries no package manager governs (provenance:"binary",
1492
+ // no resolved version) — surfaced as a count in the exec summary's rest line.
1493
+ const nativeBinaryCount = [...(resolvedDeps?.values?.() || [])].filter(d => d.provenance === "binary").length;
1494
+
1442
1495
  const exec = renderExecutiveSummary({
1443
1496
  projectInfo, prodStats, devStats, retireStats,
1444
1497
  eolResults, obsoleteResults, outdatedResults,
1445
1498
  warnings, totalDeps: resolvedDeps?.size || 0,
1446
- topCriticalMatches: pickTopCriticalMatches(prodMatchesActive, retireMatches, 5),
1499
+ topCriticalMatches: pickTopCriticalMatches(prodMatchesActive, 3),
1500
+ nativeBinaryCount,
1447
1501
  interactive: wrapTables, // copy button + scripts only in the HTML output
1448
1502
  });
1449
1503
 
@@ -1466,6 +1520,9 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1466
1520
  fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
1467
1521
  });
1468
1522
 
1523
+ // Overview charts row, rendered right under the compact totals.
1524
+ const chartsRow = renderCharts({ prodMatches: prodMatchesActive }, { interactive: wrapTables });
1525
+
1469
1526
  const body = `
1470
1527
  <header class="report-header">
1471
1528
  <h1>FAD-Checker Report</h1>
@@ -1477,6 +1534,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1477
1534
  </header>
1478
1535
  ${exec}
1479
1536
  ${summaryCards(prodStats, eolResults, obsoleteResults, outdatedResults, { warnings, vendored: retireMatches.length, licenseFlagged })}
1537
+ ${chartsRow}
1480
1538
  ${toc}
1481
1539
  ${toolbar}
1482
1540
 
@@ -1497,30 +1555,38 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1497
1555
  return wrapTables ? wrapTablesWithCopyButtons(body) : body;
1498
1556
  }
1499
1557
 
1558
+ // Descending composite-priority comparator: KEV first, then the blended
1559
+ // CVSS+EPSS score, tie-broken on having an actionable fix, then CVE id.
1560
+ function byCvePriority(a, b) {
1561
+ const pa = a.cve?.priority || computePriority(a.cve || {});
1562
+ const pb = b.cve?.priority || computePriority(b.cve || {});
1563
+ const ka = pa.sortKey || [0, 0], kb = pb.sortKey || [0, 0];
1564
+ if (kb[0] !== ka[0]) return kb[0] - ka[0];
1565
+ if (kb[1] !== ka[1]) return kb[1] - ka[1];
1566
+ const af = a.cve?.fixVersion ? 1 : 0, bf = b.cve?.fixVersion ? 1 : 0;
1567
+ if (bf !== af) return bf - af;
1568
+ return String(a.cve?.id || "").localeCompare(String(b.cve?.id || ""));
1569
+ }
1570
+
1571
+ function coordKeyOf(dep) {
1572
+ return dep.coordKey || `${dep.ecosystem === "npm" ? "npm:" : (dep.groupId || "") + ":"}${dep.artifactId}`;
1573
+ }
1574
+
1500
1575
  /**
1501
- * Pick the most impactful matches to preview in the exec summary.
1502
- * Sort by severity rank, prefer entries with a fix-version (more actionable),
1503
- * dedupe per dep coord so the preview shows diversity instead of 5 rows of
1504
- * the same tomcat CVE.
1576
+ * Pick the N most critical matches for the exec summary, scoped to NON-DEV
1577
+ * dependencies and biased to DIRECT (declared) deps: direct findings fill the
1578
+ * slots first (ranked by composite priority); only if fewer than N direct
1579
+ * findings exist do we top up with the worst TRANSITIVE ones (flagged as such
1580
+ * downstream). Deduped per dep coord so the list shows distinct libraries.
1505
1581
  */
1506
- function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1507
- const rank = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOWN: 0 };
1508
- const all = [...prodMatches, ...retireMatches]
1509
- .filter(m => (rank[(m.cve?.severity || "").toUpperCase()] || 0) >= 3); // HIGH+
1510
- all.sort((a, b) => {
1511
- const sa = rank[(a.cve.severity || "").toUpperCase()] || 0;
1512
- const sb = rank[(b.cve.severity || "").toUpperCase()] || 0;
1513
- if (sb !== sa) return sb - sa;
1514
- // Prefer those with a known fixVersion (actionable)
1515
- const af = a.cve.fixVersion ? 1 : 0, bf = b.cve.fixVersion ? 1 : 0;
1516
- if (bf !== af) return bf - af;
1517
- return (a.cve.id || "").localeCompare(b.cve.id || "");
1518
- });
1582
+ function pickTopCriticalMatches(prodMatches, n) {
1583
+ const isTransitive = m => m.dep?.scope === "transitive";
1584
+ const direct = (prodMatches || []).filter(m => !isTransitive(m)).slice().sort(byCvePriority);
1585
+ const transitive = (prodMatches || []).filter(isTransitive).slice().sort(byCvePriority);
1519
1586
  const seen = new Set();
1520
1587
  const out = [];
1521
- for (const m of all) {
1522
- const dep = m.dep;
1523
- const k = dep.coordKey || `${dep.ecosystem === "npm" ? "npm:" : dep.groupId + ":"}${dep.artifactId}`;
1588
+ for (const m of [...direct, ...transitive]) { // direct-first, transitive fallback
1589
+ const k = coordKeyOf(m.dep);
1524
1590
  if (seen.has(k)) continue;
1525
1591
  seen.add(k);
1526
1592
  out.push(m);
@@ -1529,6 +1595,25 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1529
1595
  return out;
1530
1596
  }
1531
1597
 
1598
+ /**
1599
+ * The 2 (or N) most significant end-of-life findings for the exec summary:
1600
+ * direct frameworks first, then the most overdue (oldest EOL date). Findings
1601
+ * with no concrete EOL date (eol === "true") sort after dated ones.
1602
+ */
1603
+ function pickTopEol(eolResults, n) {
1604
+ const dateMs = e => {
1605
+ if (!e || !e.eol || e.eol === "true") return Number.POSITIVE_INFINITY;
1606
+ const t = Date.parse(e.eol);
1607
+ return Number.isNaN(t) ? Number.POSITIVE_INFINITY : t;
1608
+ };
1609
+ const directRank = e => (e?.dep?.scope === "transitive" ? 1 : 0);
1610
+ return (eolResults || []).slice().sort((a, b) => {
1611
+ const da = directRank(a), db = directRank(b);
1612
+ if (da !== db) return da - db;
1613
+ return dateMs(a) - dateMs(b);
1614
+ }).slice(0, n);
1615
+ }
1616
+
1532
1617
  function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
1533
1618
  const entries = [];
1534
1619
  if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
@@ -1774,9 +1859,11 @@ const COPY_SCRIPT = `
1774
1859
  return writeClipboard(clone.outerHTML, tableToTsv(table), btn);
1775
1860
  }
1776
1861
  document.querySelectorAll('.btn-copy').forEach(function(btn){
1777
- // The executive-summary button is wired separately (it copies a prose
1778
- // summary, not a table).
1862
+ // The executive-summary button and the chart buttons are wired separately
1863
+ // (prose summary / SVG→PNG). Skipping them here also avoids the table
1864
+ // handler's stopPropagation() swallowing the chart click.
1779
1865
  if (btn.classList.contains('exec-copy-btn')) return;
1866
+ if (btn.classList.contains('chart-copy')) return;
1780
1867
  btn.addEventListener('click', function(e){
1781
1868
  e.stopPropagation();
1782
1869
  var wrap = btn.closest('.table-wrap');
@@ -1849,10 +1936,86 @@ const TOGGLE_SCRIPT = `
1849
1936
  })();
1850
1937
  </script>`;
1851
1938
 
1939
+ // Chart copy: rasterise the clicked card's <svg> to PNG (SVG → Image → canvas →
1940
+ // toBlob) and put image/png on the clipboard so it pastes into Word as a picture.
1941
+ // A white background is painted first (Word has no page background) and the canvas
1942
+ // is scaled ×3 for a crisp paste. Falls back to copying the SVG markup as rich
1943
+ // HTML when the browser lacks image-clipboard support.
1944
+ const CHART_COPY_SCRIPT = `
1945
+ <script>
1946
+ (function(){
1947
+ function flash(btn, ok){
1948
+ if(!btn) return;
1949
+ var t = btn.textContent;
1950
+ btn.classList.add(ok ? 'flashing-ok' : 'flashing-err');
1951
+ btn.textContent = ok ? '✓' : '✕';
1952
+ setTimeout(function(){ btn.classList.remove('flashing-ok','flashing-err'); btn.textContent = t; }, 1100);
1953
+ }
1954
+ function fallbackHtml(svg, btn){
1955
+ try {
1956
+ var xml = new XMLSerializer().serializeToString(svg);
1957
+ var item = new ClipboardItem({'text/html': new Blob([xml], {type:'text/html'})});
1958
+ navigator.clipboard.write([item]).then(function(){ flash(btn,true); }).catch(function(){ flash(btn,false); });
1959
+ } catch(e){ flash(btn,false); }
1960
+ }
1961
+ function roundRectPath(ctx, x, y, w, h, r){
1962
+ if (ctx.roundRect){ ctx.beginPath(); ctx.roundRect(x, y, w, h, r); return; }
1963
+ ctx.beginPath();
1964
+ ctx.moveTo(x+r, y);
1965
+ ctx.arcTo(x+w, y, x+w, y+h, r);
1966
+ ctx.arcTo(x+w, y+h, x, y+h, r);
1967
+ ctx.arcTo(x, y+h, x, y, r);
1968
+ ctx.arcTo(x, y, x+w, y, r);
1969
+ ctx.closePath();
1970
+ }
1971
+ function svgToPng(svg, ok, err){
1972
+ try {
1973
+ var xml = new XMLSerializer().serializeToString(svg);
1974
+ var vb = svg.viewBox && svg.viewBox.baseVal;
1975
+ var w = (vb && vb.width) || svg.clientWidth || 250;
1976
+ var h = (vb && vb.height) || svg.clientHeight || 150;
1977
+ var img = new Image();
1978
+ img.onload = function(){
1979
+ // Draw the chart inside a rounded white card with an outer margin,
1980
+ // inner padding and a light border — so the pasted PNG matches the
1981
+ // on-screen card instead of being a flush, square-cornered image.
1982
+ var scale = 3, M = 8, P = 12, RAD = 8;
1983
+ var cw = w + 2*P + 2*M, ch = h + 2*P + 2*M;
1984
+ var c = document.createElement('canvas');
1985
+ c.width = Math.round(cw*scale); c.height = Math.round(ch*scale);
1986
+ var ctx = c.getContext('2d');
1987
+ ctx.setTransform(scale,0,0,scale,0,0); // transparent margin (corners show through)
1988
+ roundRectPath(ctx, M, M, w + 2*P, h + 2*P, RAD);
1989
+ ctx.fillStyle = '#ffffff'; ctx.fill();
1990
+ ctx.lineWidth = 1; ctx.strokeStyle = '#e5e7eb'; ctx.stroke();
1991
+ ctx.drawImage(img, M+P, M+P, w, h);
1992
+ c.toBlob(function(blob){ blob ? ok(blob) : err(); }, 'image/png');
1993
+ };
1994
+ img.onerror = err;
1995
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml);
1996
+ } catch(e){ err(); }
1997
+ }
1998
+ document.addEventListener('click', function(ev){
1999
+ var btn = ev.target.closest && ev.target.closest('.chart-copy');
2000
+ if(!btn) return;
2001
+ var card = btn.closest('.chart-card');
2002
+ var svg = card && card.querySelector('svg.chart-svg');
2003
+ if(!svg) return;
2004
+ svgToPng(svg, function(blob){
2005
+ if(navigator.clipboard && window.ClipboardItem){
2006
+ navigator.clipboard.write([new ClipboardItem({'image/png': blob})])
2007
+ .then(function(){ flash(btn,true); })
2008
+ .catch(function(){ fallbackHtml(svg, btn); });
2009
+ } else { fallbackHtml(svg, btn); }
2010
+ }, function(){ fallbackHtml(svg, btn); });
2011
+ });
2012
+ })();
2013
+ </script>`;
2014
+
1852
2015
  function generateHtmlReport(payload) {
1853
2016
  return `<!doctype html>
1854
2017
  <html><head><meta charset="utf-8"><title>FAD-Checker Report</title><style>${CSS}</style></head>
1855
- <body>${buildBody(payload)}${TOGGLE_SCRIPT}${COPY_SCRIPT}</body></html>`;
2018
+ <body>${buildBody(payload)}${TOGGLE_SCRIPT}${COPY_SCRIPT}${CHART_COPY_SCRIPT}</body></html>`;
1856
2019
  }
1857
2020
 
1858
2021
  // Word-specific CSS tweaks layered on top of the shared CSS:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "2.2.3",
3
+ "version": "2.2.4",
4
4
  "description": "Scan ALL Maven, npm, Yarn, Composer, Python, C#/.NET, Go & Ruby dependencies — plus embedded JARs (fat-jars/war/ear) — in a source tree ONE SHOT without mvn/python/etc — CVE (EPSS/KEV-prioritised), EOL, obsolete, outdated & licenses, with SBOM/CSAF/SARIF/JSON exports, CI gating and fix recos",
5
5
  "keywords": [
6
6
  "sca",