fad-checker 2.0.1 → 2.1.1

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +91 -17
  3. package/completions/fad-checker.bash +1 -1
  4. package/completions/fad-checker.zsh +14 -1
  5. package/data/license-policy.json +97 -0
  6. package/fad-checker-report/cve-report.doc +630 -0
  7. package/fad-checker-report/cve-report.html +748 -0
  8. package/fad-checker.js +278 -53
  9. package/lib/cache-archive.js +3 -0
  10. package/lib/codecs/codec.interface.js +3 -0
  11. package/lib/codecs/composer/parse.js +3 -0
  12. package/lib/codecs/composer/registry.js +13 -5
  13. package/lib/codecs/composer.codec.js +3 -0
  14. package/lib/codecs/go/parse.js +65 -0
  15. package/lib/codecs/go/registry.js +74 -0
  16. package/lib/codecs/go.codec.js +76 -0
  17. package/lib/codecs/index.js +7 -2
  18. package/lib/codecs/maven/jar-scan.js +199 -0
  19. package/lib/codecs/maven.codec.js +17 -1
  20. package/lib/codecs/npm/collect.js +11 -0
  21. package/lib/codecs/npm/parse.js +3 -0
  22. package/lib/codecs/npm/registry.js +10 -2
  23. package/lib/codecs/npm.codec.js +3 -0
  24. package/lib/codecs/nuget/parse.js +3 -0
  25. package/lib/codecs/nuget/registry.js +11 -4
  26. package/lib/codecs/nuget.codec.js +3 -0
  27. package/lib/codecs/pypi/parse.js +7 -2
  28. package/lib/codecs/pypi/registry.js +24 -5
  29. package/lib/codecs/pypi.codec.js +3 -0
  30. package/lib/codecs/recipes.js +28 -1
  31. package/lib/codecs/ruby/parse.js +42 -0
  32. package/lib/codecs/ruby/registry.js +76 -0
  33. package/lib/codecs/ruby.codec.js +66 -0
  34. package/lib/codecs/select.js +3 -0
  35. package/lib/codecs/yarn.codec.js +3 -0
  36. package/lib/config.js +3 -0
  37. package/lib/core.js +3 -0
  38. package/lib/cpe.js +30 -5
  39. package/lib/csaf-export.js +159 -0
  40. package/lib/cve-download.js +3 -0
  41. package/lib/cve-match.js +12 -1
  42. package/lib/cve-report.js +157 -28
  43. package/lib/dep-record.js +15 -2
  44. package/lib/deps-descriptor.js +3 -0
  45. package/lib/epss.js +115 -0
  46. package/lib/gate.js +45 -0
  47. package/lib/json-export.js +110 -0
  48. package/lib/kev.js +88 -0
  49. package/lib/license-policy.js +110 -0
  50. package/lib/maven-license.js +52 -0
  51. package/lib/maven-repo.js +3 -0
  52. package/lib/maven-version.js +8 -3
  53. package/lib/nvd.js +13 -3
  54. package/lib/osv.js +68 -19
  55. package/lib/outdated.js +3 -0
  56. package/lib/priority.js +90 -0
  57. package/lib/purl.js +77 -0
  58. package/lib/retire.js +3 -0
  59. package/lib/sarif-export.js +134 -0
  60. package/lib/sbom-export.js +153 -0
  61. package/lib/scan-completeness.js +3 -0
  62. package/lib/snyk.js +3 -0
  63. package/lib/suppress.js +113 -0
  64. package/lib/transitive.js +3 -0
  65. package/lib/ui.js +3 -0
  66. package/package.json +48 -2
package/lib/cve-report.js CHANGED
@@ -4,10 +4,14 @@
4
4
  *
5
5
  * Word opens HTML saved as .doc natively — we use the same HTML body
6
6
  * wrapped in Word XML namespace meta tags.
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
7
10
  */
8
11
  const fs = require("fs");
9
12
  const path = require("path");
10
13
  const { getCodec, allCodecs, ORDER } = require("./codecs");
14
+ const { computePriority, sortByPriority } = require("./priority");
11
15
 
12
16
  // CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
13
17
  // back to the raw id (with a "—" placeholder name where the row asks for one).
@@ -34,13 +38,14 @@ function esc(s) {
34
38
  }
35
39
 
36
40
  function computeStats(cveMatches) {
37
- const buckets = { critical: 0, high: 0, medium: 0, low: 0, none: 0, unknown: 0, transitive: 0, direct: 0 };
41
+ const buckets = { critical: 0, high: 0, medium: 0, low: 0, none: 0, unknown: 0, transitive: 0, direct: 0, kev: 0 };
38
42
  for (const m of cveMatches || []) {
39
43
  const sev = (m.cve.severity || "UNKNOWN").toLowerCase();
40
44
  if (buckets[sev] != null) buckets[sev]++;
41
45
  else buckets.unknown++;
42
46
  if (m.dep?.scope === "transitive") buckets.transitive++;
43
47
  else buckets.direct++;
48
+ if (m.cve?.kev) buckets.kev++;
44
49
  }
45
50
  buckets.total = (cveMatches || []).length;
46
51
  return buckets;
@@ -61,6 +66,26 @@ function badge(sev) {
61
66
  return `<span style="display:inline-block;padding:2px 8px;border-radius:3px;background:${c.bg};color:${c.fg};font-weight:600;font-size:11px;letter-spacing:.5px">${esc(s)}</span>`;
62
67
  }
63
68
 
69
+ const BAND_BADGE = {
70
+ exploited: { bg: "#7c0008", fg: "#fff", label: "EXPLOITED" },
71
+ critical: { bg: "#b91c1c", fg: "#fff", label: "CRITICAL" },
72
+ high: { bg: "#ea580c", fg: "#fff", label: "HIGH" },
73
+ medium: { bg: "#ca8a04", fg: "#fff", label: "MEDIUM" },
74
+ low: { bg: "#2563eb", fg: "#fff", label: "LOW" },
75
+ };
76
+
77
+ // Priority cell: composite band badge (KEV-aware) + EPSS percentile + KEV chip.
78
+ function priorityCell(m) {
79
+ const p = m.cve?.priority || computePriority(m.cve || {});
80
+ const b = BAND_BADGE[p.band] || BAND_BADGE.low;
81
+ const bandBadge = `<span class="prio-band" style="display:inline-block;padding:2px 7px;border-radius:3px;background:${b.bg};color:${b.fg};font-weight:700;font-size:10px;letter-spacing:.4px">${esc(b.label)}</span>`;
82
+ const kev = p.kev ? `<div class="kev-chip" title="CISA Known Exploited Vulnerability${m.cve?.kevDueDate ? ` — remediate by ${esc(m.cve.kevDueDate)}` : ""}">🛑 KEV${m.cve?.kevRansomware ? " · ransomware" : ""}</div>` : "";
83
+ const epss = m.cve?.epssPercentile != null
84
+ ? `<div class="epss-chip" title="EPSS score ${(m.cve.epssScore != null ? (m.cve.epssScore * 100).toFixed(1) : "?")}%">EPSS ${Math.round(m.cve.epssPercentile * 100)}%</div>`
85
+ : "";
86
+ return `${bandBadge}${kev}${epss}`;
87
+ }
88
+
64
89
  const CSS = `
65
90
  * { box-sizing: border-box; }
66
91
  body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; color: #1f2937; margin: 24px; background: #f9fafb; }
@@ -95,6 +120,19 @@ h2 { margin: 32px 0 12px; font-size: 20px; border-bottom: 2px solid #e5e7eb; pad
95
120
  .exec-summary .exec-ver { color: #6b7280; }
96
121
  .exec-summary .exec-fix { color: #065f46; font-weight: 600; }
97
122
  .fp-intro { background: #f3f4f6; border-left: 3px solid #6b7280; padding: 10px 14px; margin: 8px 0 16px; font-size: 12px; color: #4b5563; line-height: 1.5; }
123
+ td.prio { white-space: normal; }
124
+ td.prio .prio-band { white-space: nowrap; }
125
+ td.prio .kev-chip { display: block; margin-top: 3px; font-size: 10px; font-weight: 700; color: #7c0008; white-space: nowrap; }
126
+ td.prio .epss-chip { display: block; margin-top: 2px; font-size: 10px; color: #6b7280; font-family: ui-monospace, monospace; white-space: nowrap; }
127
+ .summary .card.sev-kev { border-left: 4px solid #7c0008; background: #fff1f2; }
128
+ .summary .card.sev-kev .v { color: #7c0008; }
129
+ .lic-group { margin: 14px 0; }
130
+ .lic-group .lic-h { font-size: 14px; margin: 8px 0 4px; padding: 4px 8px; border-radius: 4px; }
131
+ .lic-group .lic-n { font-size: 11px; color: #6b7280; font-weight: 500; }
132
+ .lic-group.lic-strong .lic-h { background: #fee2e2; color: #7c0008; }
133
+ .lic-group.lic-weak .lic-h { background: #fef3c7; color: #92400e; }
134
+ .lic-group.lic-unknown .lic-h { background: #e5e7eb; color: #374151; }
135
+ .lic-group.lic-ok .lic-h { background: #dcfce7; color: #065f46; }
98
136
  td.cwe { font-family: ui-monospace, monospace; font-size: 11px; color: #4b5563; }
99
137
  td.cwe .cwe-link { color: #6366f1; text-decoration: none; }
100
138
  td.cwe .cwe-link:hover { text-decoration: underline; }
@@ -479,6 +517,9 @@ function renderDetailPanel(m) {
479
517
  const kv = [];
480
518
  if (cve.cvssVector) kv.push(`<div><label>${esc(cve.cvssVersion || "CVSS")}</label><span class="cvss-vector">${esc(cve.cvssVector)}</span></div>`);
481
519
  if (cve.score != null) kv.push(`<div><label>Base score</label><span>${esc(cve.score.toFixed ? cve.score.toFixed(1) : cve.score)} (${esc(cve.severity || "?")})</span></div>`);
520
+ if (cve.priority) kv.push(`<div><label>Priority</label><span>${esc((cve.priority.band || "").toUpperCase())} · ${esc(cve.priority.score)}/100</span></div>`);
521
+ if (cve.epssPercentile != null) kv.push(`<div><label>EPSS</label><span>${(cve.epssScore != null ? (cve.epssScore * 100).toFixed(1) + "% prob · " : "")}${Math.round(cve.epssPercentile * 100)}th pct</span></div>`);
522
+ if (cve.kev) kv.push(`<div><label>CISA KEV</label><span>Known exploited${cve.kevDateAdded ? ` · added ${esc(cve.kevDateAdded)}` : ""}${cve.kevDueDate ? ` · due ${esc(cve.kevDueDate)}` : ""}${cve.kevRansomware ? " · ransomware" : ""}</span></div>`);
482
523
  if (cve.fixVersion) kv.push(`<div><label>Fix version</label><span>${esc(cve.fixVersion)}</span></div>`);
483
524
  if (cve.published) kv.push(`<div><label>Published</label><span>${esc(cve.published.slice(0, 10))}</span></div>`);
484
525
  if (cve.modified) kv.push(`<div><label>Modified</label><span>${esc(cve.modified.slice(0, 10))}</span></div>`);
@@ -585,7 +626,7 @@ function renderCveRow(m, opts = {}) {
585
626
  const source = sourceParts.map(s => `<span class="source ${esc(s)}">${esc(s)}</span>`).join("");
586
627
  const conf = m.confidence ? `<div class="confidence">${esc(m.confidence)}</div>` : "";
587
628
  const actionCell = withAction ? `<td class="action">${renderActionCell(m)}</td>` : "";
588
- const baseCols = 8; // Sev, CVE, Dep, CWE, Published, Description, Fix, Source
629
+ const baseCols = 9; // Priority, Sev, CVE, Dep, CWE, Published, Description, Fix, Source
589
630
  const colspan = withAction ? baseCols + 1 : baseCols;
590
631
  const pub = formatPubDate(m.cve.published);
591
632
  const cwes = formatCwes(m.cve.cwes);
@@ -593,6 +634,7 @@ function renderCveRow(m, opts = {}) {
593
634
  // tr.detail-row. JS toggle is at the bottom of the document; links inside
594
635
  // cells stay clickable (the handler ignores anchor/button clicks).
595
636
  return `<tr class="cve-row${m.cpeFiltered ? " cpe-fp" : ""}">
637
+ <td class="prio">${priorityCell(m)}</td>
596
638
  <td>${badge(m.cve.severity)}${m.cve.score != null ? `<div class="confidence">${esc(m.cve.score.toFixed(1))}</div>` : ""}</td>
597
639
  <td class="cve"><a href="${link}" target="_blank">${esc(m.cve.id)}</a>${conf}${m.cpeFiltered ? `<div class="cpe-tag">CPE-filtered</div>` : ""}</td>
598
640
  ${depCell(m.dep)}
@@ -629,10 +671,11 @@ function renderActionCell(m) {
629
671
  function renderCveTable(cveMatches, opts = {}) {
630
672
  if (!cveMatches?.length) return `<div class="empty">No CVEs matched.</div>`;
631
673
  const { withAction } = opts;
632
- const rows = cveMatches.map(m => renderCveRow(m, opts)).join("\n");
674
+ // Highest composite priority (KEV EPSS-weighted CVSS) first.
675
+ const rows = sortByPriority(cveMatches).map(m => renderCveRow(m, opts)).join("\n");
633
676
  const actionHeader = withAction ? `<th>Recommended action</th>` : "";
634
677
  return `<table>
635
- <thead><tr><th>Severity</th><th>CVE ID</th><th>Dependency</th><th>CWE</th><th>Published</th><th>Description</th><th>Fix Version</th><th>Source</th>${actionHeader}</tr></thead>
678
+ <thead><tr><th>Priority</th><th>Severity</th><th>CVE ID</th><th>Dependency</th><th>CWE</th><th>Published</th><th>Description</th><th>Fix Version</th><th>Source</th>${actionHeader}</tr></thead>
636
679
  <tbody>${rows}</tbody>
637
680
  </table>`;
638
681
  }
@@ -966,6 +1009,7 @@ function summaryCards(stats, eol, obs, out, extra = {}) {
966
1009
  { v: stats.high, l: "High", cls: "sev-high" },
967
1010
  { v: stats.medium, l: "Medium", cls: "sev-medium" },
968
1011
  { v: stats.low, l: "Low", cls: "sev-low" },
1012
+ ...(stats.kev ? [{ v: stats.kev, l: "KEV exploited", cls: "sev-kev" }] : []),
969
1013
  { v: stats.total, l: "Total CVEs" },
970
1014
  { v: stats.direct, l: "in Direct" },
971
1015
  { v: stats.transitive, l: "in Transitive" },
@@ -973,6 +1017,7 @@ function summaryCards(stats, eol, obs, out, extra = {}) {
973
1017
  { v: eol?.length || 0, l: "EOL" },
974
1018
  { v: obs?.length || 0, l: "Obsolete" },
975
1019
  { v: out?.length || 0, l: "Outdated" },
1020
+ ...(extra.licenseFlagged ? [{ v: extra.licenseFlagged, l: "Licenses to review", cls: "sev-medium" }] : []),
976
1021
  { v: extra.warnings?.length || 0, l: "Scan alerts", cls: "sev-warn" },
977
1022
  ];
978
1023
  return `<div class="summary">${cards.map(c => `<div class="card${c.cls ? " " + c.cls : ""}"><div class="v">${c.v}</div><div class="l">${esc(c.l)}</div></div>`).join("")}</div>`;
@@ -1278,11 +1323,49 @@ function renderWarningItems(items, itemLimit) {
1278
1323
  return `<ul class="warn-items">${lis}${overflow}</ul>`;
1279
1324
  }
1280
1325
 
1281
- function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
1326
+ const LICENSE_CAT_META = {
1327
+ "network-copyleft": { label: "Network copyleft (AGPL/SSPL)", cls: "lic-strong" },
1328
+ "strong-copyleft": { label: "Strong copyleft (GPL)", cls: "lic-strong" },
1329
+ "proprietary": { label: "Proprietary / commercial", cls: "lic-strong" },
1330
+ "weak-copyleft": { label: "Weak copyleft (LGPL/MPL/EPL)", cls: "lic-weak" },
1331
+ "unknown": { label: "Unknown / unrecognized", cls: "lic-unknown" },
1332
+ "permissive": { label: "Permissive (MIT/Apache/BSD)", cls: "lic-ok" },
1333
+ };
1334
+ const LICENSE_CAT_ORDER = ["network-copyleft", "strong-copyleft", "proprietary", "weak-copyleft", "unknown", "permissive"];
1335
+
1336
+ function renderLicenseChapter(licenseResults) {
1337
+ if (!licenseResults || !licenseResults.assessed?.length) {
1338
+ return `<div class="empty">No license data resolved (offline, or registries returned none).</div>`;
1339
+ }
1340
+ const { byCategory } = licenseResults;
1341
+ const blocks = LICENSE_CAT_ORDER.filter(cat => byCategory[cat]?.length).map(cat => {
1342
+ const meta = LICENSE_CAT_META[cat] || { label: cat, cls: "lic-unknown" };
1343
+ const rows = byCategory[cat].slice().sort((a, b) => depDisplayName(a.dep).localeCompare(depDisplayName(b.dep))).map(e => {
1344
+ const lics = e.ids.concat(e.raw);
1345
+ return `<tr>
1346
+ <td>${depCell(e.dep)}</td>
1347
+ <td>${esc(lics.join(", ") || "—")}</td>
1348
+ <td>${esc(e.source || "—")}</td>
1349
+ </tr>`;
1350
+ }).join("");
1351
+ return `<div class="lic-group ${meta.cls}">
1352
+ <h3 class="lic-h">${esc(meta.label)} <span class="lic-n">${byCategory[cat].length}</span></h3>
1353
+ <table><thead><tr><th>Dependency</th><th>License(s)</th><th>Source</th></tr></thead><tbody>${rows}</tbody></table>
1354
+ </div>`;
1355
+ }).join("");
1356
+ const flaggedN = licenseResults.flagged?.length || 0;
1357
+ const intro = flaggedN
1358
+ ? `<div class="fp-intro">${flaggedN} dependency(ies) carry a copyleft, proprietary, or unrecognized license that may impose redistribution obligations — review against your distribution model. Permissive licenses are listed for completeness.</div>`
1359
+ : `<div class="fp-intro">All resolved licenses are permissive. Listed for completeness.</div>`;
1360
+ return intro + blocks;
1361
+ }
1362
+
1363
+ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
1282
1364
  setRenderCtx({ srcRoot: projectInfo?.src || null });
1283
1365
  // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1284
- cveMatches = mergeMatches(cveMatches || []);
1285
- devCveMatches = mergeMatches(devCveMatches || []);
1366
+ cveMatches = mergeMatches(cveMatches || []);
1367
+ devCveMatches = mergeMatches(devCveMatches || []);
1368
+ embeddedMatches = mergeMatches(embeddedMatches || []);
1286
1369
  retireMatches = retireMatches || [];
1287
1370
  attachRootUpdates(cveMatches, outdatedResults, resolvedDeps);
1288
1371
  attachRootUpdates(devCveMatches, outdatedResults, resolvedDeps);
@@ -1294,9 +1377,12 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
1294
1377
  const prodMatchesFP = cveMatches.filter(m => m.cpeFiltered);
1295
1378
  const devMatchesActive = devCveMatches.filter(m => !m.cpeFiltered);
1296
1379
  const devMatchesFP = devCveMatches.filter(m => m.cpeFiltered);
1380
+ const embMatchesActive = embeddedMatches.filter(m => !m.cpeFiltered);
1381
+ const embMatchesFP = embeddedMatches.filter(m => m.cpeFiltered);
1297
1382
 
1298
1383
  const prodStats = computeStats(prodMatchesActive);
1299
1384
  const devStats = computeStats(devMatchesActive);
1385
+ const embStats = computeStats(embMatchesActive);
1300
1386
  const retireStats = computeStats(retireMatches);
1301
1387
 
1302
1388
  // Recos are built from prod matches only (dev/test issues aren't a release blocker).
@@ -1305,7 +1391,11 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
1305
1391
  const cveContent = renderCveBySectionByEco(prodMatchesActive, "1", projectInfo?.src);
1306
1392
  const vendoredContent = renderRetireTable(retireMatches);
1307
1393
  const devCveContent = renderCveBySectionByEco(devMatchesActive, "3", projectInfo?.src);
1308
- const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP]);
1394
+ const licenseContent = renderLicenseChapter(licenseResults);
1395
+ const licenseTotal = licenseResults?.assessed?.length || 0;
1396
+ const licenseFlagged = licenseResults?.flagged?.length || 0;
1397
+ const embeddedContent = renderEmbeddedChapter(embMatchesActive, projectInfo?.src);
1398
+ const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP, ...embMatchesFP]);
1309
1399
 
1310
1400
  const toolbar = `<div class="toolbar">
1311
1401
  <button class="btn" type="button" data-action="expand-all">⊕ Expand all</button>
@@ -1334,7 +1424,10 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
1334
1424
  eolTotal: eolResults?.length || 0,
1335
1425
  obsoleteTotal: obsoleteResults?.length || 0,
1336
1426
  outdatedTotal: outdatedResults?.length || 0,
1337
- fpTotal: prodMatchesFP.length + devMatchesFP.length,
1427
+ licenseTotal,
1428
+ licenseFlagged,
1429
+ embeddedTotal: embStats.total,
1430
+ fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
1338
1431
  });
1339
1432
 
1340
1433
  const body = `
@@ -1347,19 +1440,21 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
1347
1440
  </div>
1348
1441
  </header>
1349
1442
  ${exec}
1350
- ${summaryCards(prodStats, eolResults, obsoleteResults, outdatedResults, { warnings, vendored: retireMatches.length })}
1443
+ ${summaryCards(prodStats, eolResults, obsoleteResults, outdatedResults, { warnings, vendored: retireMatches.length, licenseFlagged })}
1351
1444
  ${toc}
1352
1445
  ${toolbar}
1353
1446
 
1354
1447
  ${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
1355
1448
  ${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
1449
+ ${embStats.total ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embStats.total})`, embeddedContent, { open: embStats.total <= 50, id: "ch1e" }) : ""}
1356
1450
  ${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
1357
1451
  ${majorSection(`3. CVE in dev dependencies (${devStats.total})`, devCveContent, { open: devStats.total > 0 && devStats.total <= 50, id: "ch3" })}
1358
1452
  ${majorSection(`4. End-of-Life Frameworks (${eolResults?.length || 0})`, renderEolTable(eolResults), { id: "ch4" })}
1359
1453
  ${majorSection(`5. Obsolete / Deprecated Libraries (${obsoleteResults?.length || 0})`, renderObsoleteTable(obsoleteResults), { id: "ch5" })}
1360
1454
  ${majorSection(`6. Outdated Libraries (${outdatedResults?.length || 0})`, renderOutdatedTable(outdatedResults), { open: (outdatedResults?.length || 0) <= 50, id: "ch6" })}
1361
- ${majorSection(`7. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch7" })}
1362
- ${(prodMatchesFP.length + devMatchesFP.length) ? majorSection(`8. Appendix: Likely false positives (CPE-filtered) (${prodMatchesFP.length + devMatchesFP.length})`, fpContent, { open: false, id: "ch8" }) : ""}
1455
+ ${majorSection(`7. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged} to review` : ""})`, licenseContent, { open: licenseFlagged > 0, id: "ch7" })}
1456
+ ${majorSection(`8. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" })}
1457
+ ${(prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length) ? majorSection(`9. Appendix: Likely false positives (CPE-filtered) (${prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length})`, fpContent, { open: false, id: "ch9" }) : ""}
1363
1458
  `;
1364
1459
  return wrapTables ? wrapTablesWithCopyButtons(body) : body;
1365
1460
  }
@@ -1396,20 +1491,41 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1396
1491
  return out;
1397
1492
  }
1398
1493
 
1399
- function renderToc({ hasWarnings, prodTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, fpTotal }) {
1494
+ function renderToc({ hasWarnings, prodTotal, embeddedTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
1400
1495
  const entries = [];
1401
1496
  if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
1402
1497
  entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
1498
+ if (embeddedTotal) entries.push({ id: "ch1e", label: `1B. Embedded (${embeddedTotal})` });
1403
1499
  entries.push({ id: "ch2", label: `2. Vendored JS (${retireTotal})` });
1404
1500
  entries.push({ id: "ch3", label: `3. Dev CVE (${devTotal})` });
1405
1501
  entries.push({ id: "ch4", label: `4. EOL (${eolTotal})` });
1406
1502
  entries.push({ id: "ch5", label: `5. Obsolete (${obsoleteTotal})` });
1407
1503
  entries.push({ id: "ch6", label: `6. Outdated (${outdatedTotal})` });
1408
- entries.push({ id: "ch7", label: `7. Fix Recos` });
1409
- if (fpTotal) entries.push({ id: "ch8", label: `8. Likely FP (${fpTotal})` });
1504
+ entries.push({ id: "ch7", label: `7. Licenses (${licenseTotal || 0}${licenseFlagged ? `, ${licenseFlagged}⚠` : ""})` });
1505
+ entries.push({ id: "ch8", label: `8. Fix Recos` });
1506
+ if (fpTotal) entries.push({ id: "ch9", label: `9. Likely FP (${fpTotal})` });
1410
1507
  return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
1411
1508
  }
1412
1509
 
1510
+ // Embedded-binary CVE chapter: group findings by their top-level archive (the
1511
+ // part of the manifestPath before the first "!/"), so a fat-jar lists all the
1512
+ // vulnerable libraries shaded inside it.
1513
+ function renderEmbeddedChapter(matches, srcRoot) {
1514
+ if (!matches.length) return `<div class="empty">No CVEs found in embedded binaries.</div>`;
1515
+ const intro = `<div class="fp-intro">Maven coordinates discovered inside committed <code>.jar</code>/<code>.war</code>/<code>.ear</code> binaries (vendored libs, fat-jars) — not declared in any <code>pom.xml</code>. Patch by rebuilding/replacing the containing archive.</div>`;
1516
+ const byArchive = new Map();
1517
+ for (const m of matches) {
1518
+ const mp = (m.dep?.manifestPaths || [])[0] || "(unknown archive)";
1519
+ const top = String(mp).split("!/")[0];
1520
+ if (!byArchive.has(top)) byArchive.set(top, []);
1521
+ byArchive.get(top).push(m);
1522
+ }
1523
+ const blocks = [...byArchive.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([archive, list]) =>
1524
+ minorSection(`📦 <code class="path">${esc(archive)}</code> (${list.length})`, renderCveTable(list), { open: list.length <= 30 })
1525
+ ).join("\n");
1526
+ return intro + blocks;
1527
+ }
1528
+
1413
1529
  function renderFalsePositives(matches) {
1414
1530
  if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
1415
1531
  const intro = `<div class="fp-intro">These entries were initially matched by name but NVD's CPE configurations show your dep version is outside every vulnerable range. They are almost certainly false positives — kept here for audit transparency.</div>`;
@@ -1721,10 +1837,10 @@ const WORD_SECTION_PR = `<!--[if gte mso 9]>
1721
1837
  // so Word can lay out the columns deterministically instead of inflating wide
1722
1838
  // columns based on natural content width.
1723
1839
  const TABLE_COLGROUPS = {
1724
- // 8-col CVE table (header starts with <th>Severity</th><th>CVE ID</th>…)
1725
- cve: { match: /<thead><tr><th>Severity<\/th><th>CVE ID<\/th><th>Dependency<\/th><th>CWE<\/th><th>Published<\/th><th>Description<\/th><th>Fix Version<\/th><th>Source<\/th>(<th>Recommended action<\/th>)?<\/tr><\/thead>/,
1726
- widths8: [7, 10, 15, 10, 7, 26, 11, 14],
1727
- widths9: [6, 9, 13, 9, 6, 22, 10, 13, 12] },
1840
+ // 9-col CVE table (header starts with <th>Priority</th><th>Severity</th>…)
1841
+ cve: { match: /<thead><tr><th>Priority<\/th><th>Severity<\/th><th>CVE ID<\/th><th>Dependency<\/th><th>CWE<\/th><th>Published<\/th><th>Description<\/th><th>Fix Version<\/th><th>Source<\/th>(<th>Recommended action<\/th>)?<\/tr><\/thead>/,
1842
+ widths9: [9, 7, 9, 14, 9, 6, 22, 10, 14],
1843
+ widths10: [8, 6, 8, 12, 8, 6, 18, 9, 13, 12] },
1728
1844
  // 5-col EOL table
1729
1845
  eol: { match: /<thead><tr><th>Product<\/th><th>Dependency<\/th><th>EOL date<\/th><th>Latest<\/th><th>Notes<\/th><\/tr><\/thead>/,
1730
1846
  widths: [15, 25, 12, 12, 36] },
@@ -1748,7 +1864,7 @@ function colgroupHtml(widths) {
1748
1864
  function injectColgroups(html) {
1749
1865
  // CVE 8-col / 9-col (action variant)
1750
1866
  html = html.replace(new RegExp(`(<table[^>]*>)\\s*(${TABLE_COLGROUPS.cve.match.source})`, "g"), (m, tableTag, thead) => {
1751
- const widths = thead.includes("Recommended action") ? TABLE_COLGROUPS.cve.widths9 : TABLE_COLGROUPS.cve.widths8;
1867
+ const widths = thead.includes("Recommended action") ? TABLE_COLGROUPS.cve.widths10 : TABLE_COLGROUPS.cve.widths9;
1752
1868
  return `${tableTag}${colgroupHtml(widths)}${thead}`;
1753
1869
  });
1754
1870
  for (const key of ["eol", "obsolete", "outdated", "retire"]) {
@@ -1790,14 +1906,27 @@ ${WORD_SECTION_PR}
1790
1906
  </html>`;
1791
1907
  }
1792
1908
 
1793
- async function writeReports({ cveMatches, devCveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, resolvedDeps, projectInfo, outputDir, warnings }) {
1794
- await fs.promises.mkdir(outputDir, { recursive: true });
1795
- const payload = { cveMatches, devCveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, resolvedDeps, projectInfo, warnings };
1796
- const htmlPath = path.join(outputDir, "cve-report.html");
1797
- const docPath = path.join(outputDir, "cve-report.doc");
1798
- await fs.promises.writeFile(htmlPath, generateHtmlReport(payload));
1799
- await fs.promises.writeFile(docPath, generateWordReport(payload));
1800
- return { htmlPath, docPath };
1909
+ // Render the HTML and/or Word report. Callers pass explicit target paths;
1910
+ // a null/omitted path skips that format (so `--report-html` alone writes only HTML).
1911
+ // Back-compat: `outputDir` still writes both under the default file names.
1912
+ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
1913
+ if (outputDir) {
1914
+ if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
1915
+ if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
1916
+ }
1917
+ const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
1918
+ const written = { htmlPath: null, docPath: null };
1919
+ if (htmlPath) {
1920
+ await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
1921
+ await fs.promises.writeFile(htmlPath, generateHtmlReport(payload));
1922
+ written.htmlPath = htmlPath;
1923
+ }
1924
+ if (docPath) {
1925
+ await fs.promises.mkdir(path.dirname(path.resolve(docPath)), { recursive: true });
1926
+ await fs.promises.writeFile(docPath, generateWordReport(payload));
1927
+ written.docPath = docPath;
1928
+ }
1929
+ return written;
1801
1930
  }
1802
1931
 
1803
1932
  module.exports = {
package/lib/dep-record.js CHANGED
@@ -5,6 +5,9 @@
5
5
  * écosystèmes grâce au préfixe `ecosystem:`. `groupId`/`artifactId`/`pomPaths`
6
6
  * sont conservés comme alias rétro-compat le temps de migrer tous les
7
7
  * consommateurs vers `namespace`/`name`/`manifestPaths`.
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
8
11
  */
9
12
 
10
13
  // NuGet, Composer et PyPI sont case-insensitive : on normalise la clé en lower
@@ -32,9 +35,18 @@ function coordKeyFor(ecosystem, namespace, name) {
32
35
  }
33
36
 
34
37
  function makeDepRecord(input) {
35
- const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType } = input;
38
+ const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType, provenance = "manifest" } = input;
36
39
  const concrete = version && !/\$\{/.test(version) ? version : null;
37
40
  const manifestPaths = manifestPath ? [manifestPath] : [];
41
+ // Embedded binaries (a dep discovered INSIDE a .jar/.war/.ear, not declared in a
42
+ // manifest) must not share the Map key of a declared dep with the same g:a — else
43
+ // they'd merge and the "embedded binaries" report chapter would lose them. Key
44
+ // them by their unique physical location instead. provenance stays on the record
45
+ // so the report/exports can label & group them; "both" is set later if a merge
46
+ // across provenances ever happens.
47
+ const coordKey = provenance === "embedded" && manifestPath
48
+ ? `embedded:${manifestPath}`
49
+ : coordKeyFor(ecosystem, namespace, name);
38
50
  return {
39
51
  ecosystem,
40
52
  ecosystemType: ecosystemType || ecosystem,
@@ -42,7 +54,8 @@ function makeDepRecord(input) {
42
54
  name,
43
55
  version: version || null,
44
56
  versions: concrete ? [concrete] : [],
45
- coordKey: coordKeyFor(ecosystem, namespace, name),
57
+ coordKey,
58
+ provenance,
46
59
  scope,
47
60
  isDev: !!isDev,
48
61
  manifestPaths,
@@ -13,6 +13,9 @@
13
13
  * See docs/superpowers/specs/2026-05-30-anonymized-deps-descriptor-passi-design.md
14
14
  *
15
15
  * Pure functions: no I/O, no console. The caller does file read/write.
16
+ *
17
+ * @author: N.BRAUN
18
+ * @email: pp9ping@gmail.com
16
19
  */
17
20
  const { makeDepRecord, coordKeyFor } = require("./dep-record");
18
21
 
package/lib/epss.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * lib/epss.js — enrich matches with EPSS (Exploit Prediction Scoring System).
3
+ *
4
+ * EPSS gives the probability (0-1) a CVE will be exploited in the next 30 days,
5
+ * plus a percentile rank against all scored CVEs. FIRST.org recomputes it daily.
6
+ *
7
+ * API: https://api.first.org/data/v1/epss?cve=CVE-a,CVE-b,… (batch ≤ ~100)
8
+ * Cache: ~/.fad-checker/epss-cache.json, 24h TTL (aligned with the daily refresh).
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
12
+ */
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const os = require("os");
16
+
17
+ const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
18
+ const CACHE_PATH = path.join(CACHE_DIR, "epss-cache.json");
19
+ const CACHE_TTL_MS = 24 * 3600 * 1000;
20
+ const EPSS_BASE = "https://api.first.org/data/v1/epss";
21
+ const BATCH = 100;
22
+
23
+ function loadCache() {
24
+ try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); }
25
+ catch { return { meta: { fetchedAt: 0 }, entries: {} }; }
26
+ }
27
+
28
+ function saveCache(data) {
29
+ try {
30
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
31
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(data));
32
+ } catch { /* ignore */ }
33
+ }
34
+
35
+ /** Pure: FIRST.org JSON → Map<cveId, {score, percentile}>. */
36
+ function parseEpssResponse(json) {
37
+ const out = new Map();
38
+ for (const row of json?.data || []) {
39
+ if (!row?.cve) continue;
40
+ const score = parseFloat(row.epss);
41
+ const percentile = parseFloat(row.percentile);
42
+ out.set(row.cve, {
43
+ score: Number.isFinite(score) ? score : null,
44
+ percentile: Number.isFinite(percentile) ? percentile : null,
45
+ });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function chunk(arr, n) {
51
+ const out = [];
52
+ for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * Enrich matches in place with cve.epssScore / cve.epssPercentile.
58
+ * opts: { offline, verbose, fetcher, onProgress }
59
+ */
60
+ async function enrichEpss(matches, opts = {}) {
61
+ const { offline, verbose, fetcher = globalThis.fetch, onProgress } = opts;
62
+ const ids = new Set();
63
+ for (const m of matches || []) if (m.cve?.id?.startsWith("CVE-")) ids.add(m.cve.id);
64
+ if (!ids.size) return matches;
65
+
66
+ const cache = loadCache();
67
+ const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_TTL_MS;
68
+ if (!fresh && !offline) cache.entries = {};
69
+
70
+ const byId = new Map();
71
+ const liveIds = [];
72
+ for (const id of ids) {
73
+ if (cache.entries[id]) byId.set(id, cache.entries[id]);
74
+ else if (offline) byId.set(id, null);
75
+ else liveIds.push(id);
76
+ }
77
+
78
+ let done = 0;
79
+ const total = liveIds.length;
80
+ const report = () => { if (onProgress) onProgress(done, total); };
81
+ for (const group of chunk(liveIds, BATCH)) {
82
+ try {
83
+ const url = `${EPSS_BASE}?cve=${encodeURIComponent(group.join(","))}`;
84
+ const r = await fetcher(url, { headers: { "User-Agent": "fad-checker-epss" } });
85
+ if (r.ok) {
86
+ const parsed = parseEpssResponse(await r.json());
87
+ for (const id of group) {
88
+ // Not every CVE has an EPSS score; cache the null so we don't refetch.
89
+ const v = parsed.get(id) || { score: null, percentile: null };
90
+ cache.entries[id] = v;
91
+ byId.set(id, v);
92
+ }
93
+ } else if (verbose) {
94
+ console.warn(` EPSS HTTP ${r.status}`);
95
+ }
96
+ } catch (err) {
97
+ if (verbose) console.warn(` EPSS fetch failed: ${err.message}`);
98
+ }
99
+ done += group.length;
100
+ report();
101
+ }
102
+
103
+ cache.meta = { fetchedAt: fresh ? cache.meta.fetchedAt : Date.now() };
104
+ if (!offline) saveCache(cache);
105
+
106
+ for (const m of matches || []) {
107
+ const v = byId.get(m.cve?.id);
108
+ if (!v) continue;
109
+ if (v.score != null) m.cve.epssScore = v.score;
110
+ if (v.percentile != null) m.cve.epssPercentile = v.percentile;
111
+ }
112
+ return matches;
113
+ }
114
+
115
+ module.exports = { enrichEpss, parseEpssResponse, CACHE_PATH };
package/lib/gate.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * lib/gate.js — CI gating: decide whether a finding set should fail the build.
3
+ *
4
+ * Levels: none | low | medium | high | critical | kev. A severity level fails
5
+ * when any (non-suppressed) match is at or above it; `kev` fails only on a CISA
6
+ * known-exploited finding — the modern "patch what's actually attacked" gate.
7
+ * Pure — the caller sets process.exitCode.
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
11
+ */
12
+
13
+ const SEV_RANK = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
14
+
15
+ /**
16
+ * evaluateGate(matches, level) → { failed, reason, count, level }.
17
+ * `matches` should already exclude cpe-filtered/dev — typically the prodActive set.
18
+ */
19
+ function evaluateGate(matches, level) {
20
+ const lvl = String(level || "none").toLowerCase();
21
+ const active = (matches || []).filter(m => !m.suppressed);
22
+ if (lvl === "none") return { failed: false, reason: "gating disabled", count: 0, level: lvl };
23
+
24
+ if (lvl === "kev") {
25
+ const hits = active.filter(m => m.cve?.kev);
26
+ return {
27
+ failed: hits.length > 0,
28
+ count: hits.length,
29
+ level: lvl,
30
+ reason: hits.length ? `${hits.length} known-exploited (CISA KEV) finding(s)` : "no known-exploited finding",
31
+ };
32
+ }
33
+
34
+ const threshold = SEV_RANK[lvl];
35
+ if (threshold == null) return { failed: false, reason: `unknown level "${lvl}"`, count: 0, level: lvl };
36
+ const hits = active.filter(m => (SEV_RANK[(m.cve?.severity || "none").toLowerCase()] ?? 0) >= threshold);
37
+ return {
38
+ failed: hits.length > 0,
39
+ count: hits.length,
40
+ level: lvl,
41
+ reason: hits.length ? `${hits.length} finding(s) at or above ${lvl}` : `no finding at or above ${lvl}`,
42
+ };
43
+ }
44
+
45
+ module.exports = { evaluateGate, SEV_RANK };