fad-checker 2.3.1 → 2.4.0

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
@@ -130,6 +130,9 @@ h2 { margin: 32px 0 12px; font-size: 20px; border-bottom: 2px solid #e5e7eb; pad
130
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); }
131
131
  .toc a { color: #4338ca; text-decoration: none; padding: 2px 6px; border-radius: 3px; }
132
132
  .toc a:hover { background: #eef2ff; }
133
+ .toc a.toc-root { font-weight: 700; }
134
+ .toc a.toc-sub { font-size: 11px; color: #6b7280; padding-left: 4px; }
135
+ .toc a.toc-sub::before { content: "› "; color: #c7cdd6; }
133
136
  .exec-summary .exec-top { margin-top: 8px; padding-top: 10px; border-top: 1px dashed #e5e7eb; }
134
137
  .exec-summary .exec-top-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: #6b7280; margin-bottom: 6px; }
135
138
  .exec-summary .exec-top-list { list-style: none; padding: 0; margin: 0; font-size: 12px; }
@@ -1170,18 +1173,23 @@ function attachRootUpdates(cveMatches, outdatedResults, resolvedDeps) {
1170
1173
  }
1171
1174
  }
1172
1175
 
1176
+ // Every chapter / sub-chapter renders EXPANDED by default (the user wants the whole
1177
+ // report open on load). The `open` opt is accepted for call-site compatibility but
1178
+ // intentionally ignored — collapse is still available via the toolbar / clicking a
1179
+ // header. (Per-CVE detail rows are NOT <details>, so they stay collapsed-by-default.)
1173
1180
  function majorSection(title, contentHtml, opts = {}) {
1174
- const { open = true, id } = opts;
1181
+ const { id } = opts;
1175
1182
  const idAttr = id ? ` id="${esc(id)}"` : "";
1176
- return `<details class="report-section"${open ? " open" : ""}${idAttr}>
1183
+ return `<details class="report-section" open${idAttr}>
1177
1184
  <summary><h2>${title}</h2></summary>
1178
1185
  <div>${contentHtml}</div>
1179
1186
  </details>`;
1180
1187
  }
1181
1188
 
1182
1189
  function minorSection(title, contentHtml, opts = {}) {
1183
- const { open = true } = opts;
1184
- return `<details class="report-subsection"${open ? " open" : ""}>
1190
+ const { id } = opts;
1191
+ const idAttr = id ? ` id="${esc(id)}"` : "";
1192
+ return `<details class="report-subsection" open${idAttr}>
1185
1193
  <summary><h3>${title}</h3></summary>
1186
1194
  <div>${contentHtml}</div>
1187
1195
  </details>`;
@@ -1485,7 +1493,7 @@ function renderLicenseChapter(licenseResults) {
1485
1493
  return intro + blocks;
1486
1494
  }
1487
1495
 
1488
- function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, parsedManifests = [], wrapTables = true }) {
1496
+ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs = [], resolvedDeps, projectInfo, warnings, parsedManifests = [], diff = null, wrapTables = true }) {
1489
1497
  setRenderCtx({ srcRoot: projectInfo?.src || null });
1490
1498
  // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1491
1499
  cveMatches = mergeMatches(cveMatches || []);
@@ -1513,9 +1521,9 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1513
1521
  // Recos are built from prod matches only (dev/test issues aren't a release blocker).
1514
1522
  const recos = buildFixRecommendations(prodMatchesActive, outdatedResults, resolvedDeps);
1515
1523
 
1516
- const cveContent = renderCveBySectionByEco(prodMatchesActive, "1", projectInfo?.src);
1524
+ const cveContent = renderCveBySectionByEco(prodMatchesActive, "1.1", projectInfo?.src);
1517
1525
  const vendoredContent = renderRetireTable(retireMatches);
1518
- const devCveContent = renderCveBySectionByEco(devMatchesActive, "3", projectInfo?.src);
1526
+ const devCveContent = renderCveBySectionByEco(devMatchesActive, "1.3", projectInfo?.src);
1519
1527
  const licenseContent = renderLicenseChapter(licenseResults);
1520
1528
  const licenseTotal = licenseResults?.assessed?.length || 0;
1521
1529
  const licenseFlagged = licenseResults?.flagged?.length || 0;
@@ -1548,26 +1556,86 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1548
1556
  interactive: wrapTables, // copy button + scripts only in the HTML output
1549
1557
  });
1550
1558
 
1551
- // (C) Table of contents rendered as a sticky nav. Only enumerates the
1552
- // chapters that actually have content (warnings, dev CVE, retire, FP appendix
1553
- // might be empty).
1559
+ // ---- Chapters are grouped under "root" chapters (a two-level hierarchy) ----
1554
1560
  const scanned = buildScannedManifests(resolvedDeps, projectInfo?.src, parsedManifests);
1555
- const toc = renderToc({
1556
- hasWarnings: !!(warnings?.length),
1557
- prodTotal: prodStats.total,
1558
- scannedTotal: scanned.length,
1559
- retireTotal: retireMatches.length,
1560
- devTotal: devStats.total,
1561
- eolTotal: eolResults?.length || 0,
1562
- obsoleteTotal: obsoleteResults?.length || 0,
1563
- outdatedTotal: outdatedResults?.length || 0,
1564
- licenseTotal,
1565
- licenseFlagged,
1566
- embeddedTotal: embeddedInventory.length,
1567
- unmanagedTotal: unmanagedInventory.length,
1568
- vendoredJsTotal: vendoredJsInv.length,
1569
- fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
1570
- });
1561
+ const fpTotal = prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length;
1562
+ const eolTotal = eolResults?.length || 0;
1563
+ const obsTotal = obsoleteResults?.length || 0;
1564
+ const outTotal = outdatedResults?.length || 0;
1565
+ const embTotal = embeddedInventory.length;
1566
+ const unmTotal = unmanagedInventory.length;
1567
+ const vjsTotal = vendoredJsInv.length;
1568
+ const excTotal = excludedDirs?.length || 0;
1569
+ // CVE root header breakdown: direct vs indirect (transitive) production vulns + the dev count.
1570
+ const prodDirect = prodMatchesActive.filter(m => m.dep?.scope !== "transitive").length;
1571
+ const prodIndirect = prodMatchesActive.filter(m => m.dep?.scope === "transitive").length;
1572
+
1573
+ // Root 1 — CVE (production + vendored-JS vulns + dev + likely false positives).
1574
+ const cveRoot = majorSection(
1575
+ `1. CVE (${prodDirect} direct, ${prodIndirect} indirect, ${devStats.total} dev)`,
1576
+ minorSection(`1.1 Production (${prodStats.total})`, cveContent, { id: "ch1", open: true }) +
1577
+ minorSection(`1.2 Vendored JS vulns — retire.js (${retireMatches.length})`, vendoredContent, { id: "ch2", open: retireMatches.length > 0 && retireMatches.length <= 50 }) +
1578
+ minorSection(`1.3 Dev dependencies (${devStats.total})`, devCveContent, { id: "ch3", open: devStats.total > 0 && devStats.total <= 50 }) +
1579
+ (fpTotal ? minorSection(`1.4 Likely false positives — CPE-filtered (${fpTotal})`, fpContent, { id: "ch9", open: false }) : ""),
1580
+ { id: "chcve" });
1581
+
1582
+ // Root 2 — Unmanaged / unversioned components (binaries + vendored JS, no package manager).
1583
+ const binRoot = majorSection(
1584
+ `2. Unmanaged / unversioned components (${embTotal} embedded, ${unmTotal} native, ${vjsTotal} vendored JS)`,
1585
+ (embTotal ? minorSection(`2.1 Embedded binaries — JAR/WAR/EAR (${embTotal})`, embeddedContent, { id: "ch1e", open: embTotal <= 50 }) : "") +
1586
+ (unmTotal ? minorSection(`2.2 Unmanaged / vendored native binaries (${unmTotal})`, unmanagedContent, { id: "ch1c", open: unmTotal <= 50 }) : "") +
1587
+ (vjsTotal ? minorSection(`2.3 Unmanaged / vendored JavaScript (${vjsTotal})`, vendoredJsContent, { id: "ch1d", open: vjsTotal <= 50 }) : "") ||
1588
+ `<div class="empty">No unmanaged binaries or vendored JavaScript found.</div>`,
1589
+ { id: "chbin", open: (embTotal + unmTotal + vjsTotal) > 0 });
1590
+
1591
+ // Root 3 — Maintenance / lifecycle (EOL + obsolete + outdated).
1592
+ const maintRoot = majorSection(
1593
+ `3. Maintenance / lifecycle (${eolTotal} EOL, ${obsTotal} obsolete, ${outTotal} outdated)`,
1594
+ minorSection(`3.1 End-of-Life frameworks (${eolTotal})`, renderEolTable(eolResults), { id: "ch4", open: eolTotal > 0 }) +
1595
+ minorSection(`3.2 Obsolete / deprecated (${obsTotal})`, renderObsoleteTable(obsoleteResults), { id: "ch5", open: obsTotal > 0 }) +
1596
+ minorSection(`3.3 Outdated (${outTotal})`, renderOutdatedTable(outdatedResults), { id: "ch6", open: outTotal > 0 && outTotal <= 50 }),
1597
+ { id: "chmaint" });
1598
+
1599
+ // Root 4 — Licenses (standalone). Root 5 — Fix Recommendations (standalone).
1600
+ const licRoot = majorSection(`4. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged} to review` : ""})`, licenseContent, { open: licenseFlagged > 0, id: "ch7" });
1601
+ const fixRoot = majorSection(`5. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" });
1602
+
1603
+ // Root 6 — Scan context & limitations (scanned descriptors + ignored dirs + methodology).
1604
+ const scanRoot = majorSection(
1605
+ `6. Scan context & limitations`,
1606
+ minorSection(`6.1 Scanned dependency descriptors (${scanned.length})`, renderScannedManifests(scanned), { id: "chsrc", open: false }) +
1607
+ (excTotal ? minorSection(`6.2 Ignored directories (${excTotal})`, renderExcludedDirs(excludedDirs), { id: "chexcl", open: false }) : "") +
1608
+ minorSection(`6.3 Methodology, data sources & limitations`, renderMethodologyChapter(projectInfo), { id: "chmethod", open: false }),
1609
+ { id: "chscan", open: false });
1610
+
1611
+ // (C) Table of contents — sticky nav, hierarchical (roots + indented sub-chapters).
1612
+ const toc = renderToc([
1613
+ ...(warnings?.length ? [{ id: "ch0", label: "0. Warnings" }] : []),
1614
+ ...(diff && diff.summary ? [{ id: "chdiff", label: "Δ Baseline diff" }] : []),
1615
+ { id: "chcve", label: `1. CVE (${prodStats.total})`, sub: [
1616
+ { id: "ch1", label: `Prod (${prodStats.total})` },
1617
+ { id: "ch2", label: `Vendored JS (${retireMatches.length})` },
1618
+ { id: "ch3", label: `Dev (${devStats.total})` },
1619
+ ...(fpTotal ? [{ id: "ch9", label: `Likely FP (${fpTotal})` }] : []),
1620
+ ] },
1621
+ { id: "chbin", label: `2. Unmanaged components (${embTotal + unmTotal + vjsTotal})`, sub: [
1622
+ ...(embTotal ? [{ id: "ch1e", label: `Embedded (${embTotal})` }] : []),
1623
+ ...(unmTotal ? [{ id: "ch1c", label: `Native (${unmTotal})` }] : []),
1624
+ ...(vjsTotal ? [{ id: "ch1d", label: `Vendored JS (${vjsTotal})` }] : []),
1625
+ ] },
1626
+ { id: "chmaint", label: `3. Maintenance (${eolTotal + obsTotal + outTotal})`, sub: [
1627
+ { id: "ch4", label: `EOL (${eolTotal})` },
1628
+ { id: "ch5", label: `Obsolete (${obsTotal})` },
1629
+ { id: "ch6", label: `Outdated (${outTotal})` },
1630
+ ] },
1631
+ { id: "ch7", label: `4. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged}⚠` : ""})` },
1632
+ { id: "ch8", label: `5. Fix Recos` },
1633
+ { id: "chscan", label: `6. Scan context`, sub: [
1634
+ { id: "chsrc", label: `Descriptors (${scanned.length})` },
1635
+ ...(excTotal ? [{ id: "chexcl", label: `Ignored dirs (${excTotal})` }] : []),
1636
+ { id: "chmethod", label: `Methodology` },
1637
+ ] },
1638
+ ]);
1571
1639
 
1572
1640
  // Overview charts row, rendered right under the compact totals.
1573
1641
  const chartsRow = renderCharts({ prodMatches: prodMatchesActive }, { interactive: wrapTables });
@@ -1588,19 +1656,13 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1588
1656
  ${toolbar}
1589
1657
 
1590
1658
  ${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
1591
- ${majorSection(`1. CVE Vulnerabilities production (${prodStats.total})`, cveContent, { id: "ch1" })}
1592
- ${embeddedInventory.length ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embeddedInventory.length})`, embeddedContent, { open: embeddedInventory.length <= 50, id: "ch1e" }) : ""}
1593
- ${unmanagedInventory.length ? majorSection(`1C. Unmanaged / vendored binaries (${unmanagedInventory.length})`, unmanagedContent, { open: unmanagedInventory.length <= 50, id: "ch1c" }) : ""}
1594
- ${vendoredJsInv.length ? majorSection(`1D. Unmanaged / vendored JavaScript (${vendoredJsInv.length})`, vendoredJsContent, { open: vendoredJsInv.length <= 50, id: "ch1d" }) : ""}
1595
- ${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
1596
- ${majorSection(`3. CVE in dev dependencies (${devStats.total})`, devCveContent, { open: devStats.total > 0 && devStats.total <= 50, id: "ch3" })}
1597
- ${majorSection(`4. End-of-Life Frameworks (${eolResults?.length || 0})`, renderEolTable(eolResults), { id: "ch4" })}
1598
- ${majorSection(`5. Obsolete / Deprecated Libraries (${obsoleteResults?.length || 0})`, renderObsoleteTable(obsoleteResults), { id: "ch5" })}
1599
- ${majorSection(`6. Outdated Libraries (${outdatedResults?.length || 0})`, renderOutdatedTable(outdatedResults), { open: (outdatedResults?.length || 0) <= 50, id: "ch6" })}
1600
- ${majorSection(`7. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged} to review` : ""})`, licenseContent, { open: licenseFlagged > 0, id: "ch7" })}
1601
- ${majorSection(`8. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" })}
1602
- ${(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" }) : ""}
1603
- ${majorSection(`10. Appendix: Scanned dependency descriptors (${scanned.length})`, renderScannedManifests(scanned), { open: false, id: "chsrc" })}
1659
+ ${(diff && diff.summary) ? majorSection(`Δ Changes since baseline (+${diff.summary.cve.addedProduction} new prod CVE)`, renderDiffChapter(diff), { open: true, id: "chdiff" }) : ""}
1660
+ ${cveRoot}
1661
+ ${binRoot}
1662
+ ${maintRoot}
1663
+ ${licRoot}
1664
+ ${fixRoot}
1665
+ ${scanRoot}
1604
1666
  `;
1605
1667
  return wrapTables ? wrapTablesWithCopyButtons(body) : body;
1606
1668
  }
@@ -1664,23 +1726,15 @@ function pickTopEol(eolResults, n) {
1664
1726
  }).slice(0, n);
1665
1727
  }
1666
1728
 
1667
- function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal, scannedTotal }) {
1668
- const entries = [];
1669
- if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
1670
- entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
1671
- if (embeddedTotal) entries.push({ id: "ch1e", label: `1B. Embedded (${embeddedTotal})` });
1672
- if (unmanagedTotal) entries.push({ id: "ch1c", label: `1C. Unmanaged (${unmanagedTotal})` });
1673
- if (vendoredJsTotal) entries.push({ id: "ch1d", label: `1D. Vendored JS (${vendoredJsTotal})` });
1674
- entries.push({ id: "ch2", label: `2. Vendored JS vulns (${retireTotal})` });
1675
- entries.push({ id: "ch3", label: `3. Dev CVE (${devTotal})` });
1676
- entries.push({ id: "ch4", label: `4. EOL (${eolTotal})` });
1677
- entries.push({ id: "ch5", label: `5. Obsolete (${obsoleteTotal})` });
1678
- entries.push({ id: "ch6", label: `6. Outdated (${outdatedTotal})` });
1679
- entries.push({ id: "ch7", label: `7. Licenses (${licenseTotal || 0}${licenseFlagged ? `, ${licenseFlagged}⚠` : ""})` });
1680
- entries.push({ id: "ch8", label: `8. Fix Recos` });
1681
- if (fpTotal) entries.push({ id: "ch9", label: `9. Likely FP (${fpTotal})` });
1682
- entries.push({ id: "chsrc", label: `10. Scanned descriptors (${scannedTotal || 0})` });
1683
- return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
1729
+ // Hierarchical TOC. `entries` is an array of { id, label, sub?: [{id,label}] };
1730
+ // roots render as bold chips, their sub-chapters as smaller muted chips after them.
1731
+ function renderToc(entries) {
1732
+ const renderOne = e => {
1733
+ const root = `<a class="toc-root" href="#${esc(e.id)}">${esc(e.label)}</a>`;
1734
+ const subs = (e.sub || []).map(s => `<a class="toc-sub" href="#${esc(s.id)}">${esc(s.label)}</a>`).join("");
1735
+ return root + subs;
1736
+ };
1737
+ return `<nav class="toc">${(entries || []).map(renderOne).join("")}</nav>`;
1684
1738
  }
1685
1739
 
1686
1740
  // Embedded-binary inventory chapter (1B): every embedded coordinate grouped by its
@@ -1759,6 +1813,76 @@ function renderVendoredJsInventory(inventory, srcRoot) {
1759
1813
  return intro + `<table><thead><tr><th>Library</th><th>Version</th><th>File</th><th>Detection</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
1760
1814
  }
1761
1815
 
1816
+ function renderExcludedDirs(excludedDirs) {
1817
+ if (!excludedDirs || !excludedDirs.length) return `<div class="empty">No directories were excluded from the scan.</div>`;
1818
+ const byDefault = excludedDirs.filter(e => e.type === "default").length;
1819
+ const byGlob = excludedDirs.filter(e => e.type === "exclude-path").length;
1820
+ const intro = `<div class="fp-intro">Directories the scan <strong>did not walk</strong> — pruned by the built-in default-exclude set (package stores, build output, VCS/IDE metadata) at any depth and by your <code>--exclude-path</code> rules, anchored to <code>--src</code>. Paths are relative to the scan root. <strong>${byDefault}</strong> pruned by default rules${byGlob ? `, <strong>${byGlob}</strong> by <code>--exclude-path</code>` : ""}. Nothing under these paths was read, so any dependency, vendored JS or binary inside them is <strong>not</strong> covered — re-run with <code>--no-default-excludes</code> or drop an <code>--exclude-path</code> rule to include one.</div>`;
1821
+ const rows = excludedDirs.map(e => {
1822
+ const tag = e.type === "exclude-path"
1823
+ ? pill("--exclude-path", "#b45309")
1824
+ : pill("default", "#6b7280");
1825
+ return `<tr><td class="dep"><code class="path">${esc(e.dir || "")}</code></td><td>${tag}</td><td><span style="color:#6b7280">${esc(e.reason || "")}</span></td></tr>`;
1826
+ }).join("\n");
1827
+ return intro + `<table><thead><tr><th>Directory (relative to --src)</th><th>Rule</th><th>Matched</th></tr></thead><tbody>${rows}</tbody></table>`;
1828
+ }
1829
+
1830
+ // What fad-checker does NOT assess — stated explicitly so the audit's scope and
1831
+ // liability are unambiguous. Keep this list honest and in sync with the README.
1832
+ const LIMITATIONS = [
1833
+ "<strong>Reachability / exploitability in your app.</strong> Findings flag a vulnerable version on the dependency graph; they do <em>not</em> prove the vulnerable code path is actually called at runtime. Triage with your application context.",
1834
+ "<strong>Runtime configuration & mitigations.</strong> WAFs, feature flags, network isolation, disabled features, and other compensating controls are not modelled.",
1835
+ "<strong>Secrets, IaC & container base images.</strong> No secret scanning, infrastructure-as-code, or OS/base-image layer analysis — fad-checker scans declared & vendored application dependencies only.",
1836
+ "<strong>Business-logic & first-party code flaws.</strong> No SAST of your own source; only third-party components are assessed.",
1837
+ "<strong>Malware beyond the available signal.</strong> Supply-chain risk uses OSV <code>MAL-</code> advisories and the free CIRCL <code>KnownMalicious</code> flag; there is no antivirus/behavioural lane.",
1838
+ "<strong>License legal advice.</strong> License classification is informational SPDX categorisation, not a legal determination.",
1839
+ "<strong>Private / internal coordinates.</strong> Components absent from public registries can't be CVE-matched here; audit them against your internal feed.",
1840
+ "<strong>Data-source recency.</strong> Results reflect the cache state in the data-source table above; an <code>--offline</code> run uses exactly those snapshots.",
1841
+ ];
1842
+
1843
+ function renderMethodologyChapter(projectInfo) {
1844
+ const prov = projectInfo && projectInfo.provenance;
1845
+ const STATUS_PILL = { cached: ["cached", "#15803d"], disabled: ["disabled", "#6b7280"], missing: ["not warmed", "#b45309"] };
1846
+ let sourcesTable = `<div class="empty">No provenance manifest available for this run.</div>`;
1847
+ let cfgHtml = "";
1848
+ if (prov) {
1849
+ const rows = (prov.dataSources || []).map(s => {
1850
+ const [txt, bg] = STATUS_PILL[s.status] || [s.status, "#6b7280"];
1851
+ return `<tr><td>${esc(s.label)}</td><td>${pill(txt, bg)}</td><td>${esc(s.asOf || "—")}</td><td><span style="color:#6b7280">${esc(s.detail || "")}</span></td></tr>`;
1852
+ }).join("\n");
1853
+ sourcesTable = `<table><thead><tr><th>Data source</th><th>State</th><th>As of</th><th>Detail</th></tr></thead><tbody>${rows}</tbody></table>`;
1854
+ const c = prov.configuration || {};
1855
+ const rt = prov.runtime || {};
1856
+ const kv = (k, v) => `<span class="meta-kv"><strong>${esc(k)}:</strong> ${esc(String(v))}</span>`;
1857
+ cfgHtml = `<div class="fp-intro" style="margin-top:14px">
1858
+ ${kv("mode", prov.mode)} ${kv("ecosystems", c.ecosystems)} ${kv("transitive", c.transitive ? `on (depth ${c.transitiveDepth || "?"})` : "off")}
1859
+ ${kv("OSV", c.osv)} ${kv("NVD", c.nvd)} ${kv("EPSS", c.epss)} ${kv("KEV", c.kev)} ${kv("licenses", c.licenses)} ${kv("typosquat", c.typosquat)}
1860
+ ${kv("fail-on", c.failOn)}${c.ignoreFile ? " " + kv("ignore", c.ignoreFile) : ""}${c.vexFile ? " " + kv("vex", c.vexFile) : ""}
1861
+ ${kv("runtime", `${rt.node || "?"} · ${rt.platform || "?"}/${rt.arch || "?"}`)}
1862
+ </div>`;
1863
+ }
1864
+ const intro = `<div class="fp-intro">How this report was produced and what it does <strong>not</strong> cover — stated for audit transparency and reproducibility. The data-source table shows the freshness of every feed used; the configuration line records the run's findings-affecting options. An <code>--offline</code> re-run against the same cache (ship it with <code>--export-cache</code>) reproduces these findings.</div>`;
1865
+ const limitations = `<div class="defined-in" style="margin-top:14px"><span class="defined-label">Limitations — fad-checker does not assess:</span></div><ul class="methodology-limits">${LIMITATIONS.map(l => `<li>${l}</li>`).join("")}</ul>`;
1866
+ return intro + sourcesTable + cfgHtml + limitations;
1867
+ }
1868
+
1869
+ // Differential-audit chapter (vs --baseline): what changed since the prior findings JSON.
1870
+ function renderDiffChapter(diff) {
1871
+ if (!diff || !diff.summary) return "";
1872
+ const s = diff.summary;
1873
+ const sevTxt = Object.entries(s.cve.addedBySeverity || {}).filter(([, n]) => n).map(([k, n]) => `${n} ${k.toLowerCase()}`).join(", ") || "none";
1874
+ const intro = `<div class="fp-intro">Change since the baseline (<code>--baseline</code>). <strong>${s.cve.addedProduction}</strong> new production CVE finding(s) (${esc(sevTxt)}); ${s.cve.removed} resolved, ${s.cve.unchanged} unchanged.</div>`;
1875
+ const cat = (label, c) => `<tr><td>${esc(label)}</td><td style="color:#b91c1c">+${c.added}</td><td style="color:#15803d">−${c.removed}</td><td><span style="color:#6b7280">${c.unchanged}</span></td></tr>`;
1876
+ const table = `<table><thead><tr><th>Category</th><th>New</th><th>Resolved</th><th>Unchanged</th></tr></thead><tbody>
1877
+ ${cat("CVE", s.cve)}${cat("EOL", s.eol)}${cat("Obsolete", s.obsolete)}${cat("Outdated", s.outdated)}${cat("Licenses", s.licenses)}
1878
+ </tbody></table>`;
1879
+ const newCves = (diff.cve?.added || []).filter(f => !f.suppressed && !f.cpeFiltered);
1880
+ const list = newCves.length
1881
+ ? `<div class="defined-in" style="margin-top:10px"><span class="defined-label">New CVE findings:</span></div><ul>${newCves.slice(0, 50).map(f => `<li><code>${esc(f.id)}</code> · ${esc(f.severity || "")} · <code>${esc((f.dep && f.dep.coord) || "")}@${esc((f.dep && f.dep.version) || "")}</code></li>`).join("")}${newCves.length > 50 ? `<li>…and ${newCves.length - 50} more</li>` : ""}</ul>`
1882
+ : "";
1883
+ return intro + table + list;
1884
+ }
1885
+
1762
1886
  function renderFalsePositives(matches) {
1763
1887
  if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
1764
1888
  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>`;
@@ -2220,12 +2344,12 @@ ${WORD_SECTION_PR}
2220
2344
  // Render the HTML and/or Word report. Callers pass explicit target paths;
2221
2345
  // a null/omitted path skips that format (so `--report-html` alone writes only HTML).
2222
2346
  // Back-compat: `outputDir` still writes both under the default file names.
2223
- async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests }) {
2347
+ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests, diff }) {
2224
2348
  if (outputDir) {
2225
2349
  if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
2226
2350
  if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
2227
2351
  }
2228
- const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, parsedManifests };
2352
+ const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, warnings, parsedManifests, diff };
2229
2353
  const written = { htmlPath: null, docPath: null };
2230
2354
  if (htmlPath) {
2231
2355
  await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
package/lib/diff.js ADDED
Binary file
@@ -68,7 +68,7 @@ function buildFindings(payload = {}) {
68
68
  const {
69
69
  cveMatches = [], retireMatches = [], vendoredJsInventory = [], eolResults = [], obsoleteResults = [],
70
70
  outdatedResults = [], licenseResults = null, resolvedDeps, projectInfo = {}, toolVersion = "0",
71
- typosquats = [],
71
+ typosquats = [], excludedDirs = [], diff = null,
72
72
  } = payload;
73
73
 
74
74
  const { buildInventory } = require("./unmanaged");
@@ -91,6 +91,9 @@ function buildFindings(payload = {}) {
91
91
  tool: { name: "fad-checker", version: String(toolVersion) },
92
92
  generatedAt: projectInfo.generatedAt || null,
93
93
  project: { name: projectInfo.name || null, src: projectInfo.src || null },
94
+ // Scan-provenance manifest (data-source freshness + run configuration) for a
95
+ // reproducible/defensible audit. Null if the caller didn't supply one.
96
+ provenance: projectInfo.provenance || payload.provenance || null,
94
97
  summary: {
95
98
  dependencies: resolvedDeps?.size ?? null,
96
99
  cve: { ...sevCounts, kev, total: cveMatches.filter(m => !m.cpeFiltered && !m.suppressed).length },
@@ -105,6 +108,7 @@ function buildFindings(payload = {}) {
105
108
  suppressed: cveMatches.filter(m => m.suppressed).length,
106
109
  malicious: cveMatches.filter(m => m.malicious && !m.suppressed).length,
107
110
  typosquat: typosquats.length,
111
+ excludedDirs: excludedDirs.length,
108
112
  },
109
113
  cve: cveMatches.map(cveFinding),
110
114
  vendored: retireMatches.map(cveFinding),
@@ -116,6 +120,10 @@ function buildFindings(payload = {}) {
116
120
  embedded,
117
121
  vendoredJs: vendoredJsInventory,
118
122
  typosquat: typosquats,
123
+ excludedDirs,
124
+ // Differential audit vs a --baseline document (summary + per-category deltas).
125
+ // Null when no baseline was provided.
126
+ diff: diff || null,
119
127
  };
120
128
  }
121
129
 
package/lib/nvd.js CHANGED
@@ -45,11 +45,18 @@ function cachePath(cveId) {
45
45
  return path.join(NVD_CACHE_DIR, `${cveId}.json`);
46
46
  }
47
47
 
48
- function readCache(cveId) {
48
+ function readCache(cveId, offline = false) {
49
49
  const p = cachePath(cveId);
50
50
  if (!fs.existsSync(p)) return null;
51
51
  try {
52
52
  const data = JSON.parse(fs.readFileSync(p, "utf8"));
53
+ // Offline (air-gapped): the warmed cache is the ONLY source — never reject it
54
+ // for age or an older schema. Dropping it would silently lose ALL NVD
55
+ // enrichment (CWEs, CVSS vector, references, CPE configs) for that CVE, which
56
+ // is exactly the "offline missed some CWE titles" regression. A stale/older
57
+ // body just means a field may be missing — strictly better than nothing.
58
+ // (Online still enforces schema + TTL so we re-fetch and upgrade.)
59
+ if (offline) return data.body ?? null;
53
60
  // Schema mismatch → force re-fetch so newly-added fields (e.g. cwes) get populated.
54
61
  if ((data._schema || 1) < NVD_CACHE_SCHEMA) return null;
55
62
  if (Date.now() - data._fetchedAt < NVD_CACHE_TTL_MS) return data.body;
@@ -165,7 +172,7 @@ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
165
172
 
166
173
  async function fetchOne(cveId, opts = {}) {
167
174
  const { fetcher = globalThis.fetch, verbose, offline } = opts;
168
- const cached = readCache(cveId);
175
+ const cached = readCache(cveId, offline);
169
176
  if (cached !== null && cached !== undefined) return cached;
170
177
  if (offline) return null;
171
178
  const headers = { "User-Agent": "fad-checker-nvd-enrich" };
@@ -210,7 +217,7 @@ async function enrichMatches(matches, opts = {}) {
210
217
  const byId = new Map();
211
218
  const liveIds = [];
212
219
  for (const cveId of uniqueCves) {
213
- const cached = readCache(cveId);
220
+ const cached = readCache(cveId, offline);
214
221
  if (cached !== null && cached !== undefined) {
215
222
  byId.set(cveId, cached);
216
223
  continue;
@@ -296,6 +303,10 @@ module.exports = {
296
303
  enrichMatches,
297
304
  fetchOne,
298
305
  extractFromNvdRecord,
306
+ readCache,
307
+ writeCache,
299
308
  NVD_CACHE_DIR,
309
+ NVD_CACHE_SCHEMA,
310
+ NVD_CACHE_TTL_MS,
300
311
  getNvdApiKey,
301
312
  };
@@ -14,6 +14,7 @@
14
14
  * @author: N.BRAUN
15
15
  * @email: pp9ping@gmail.com
16
16
  */
17
+ const fs = require("fs");
17
18
  const path = require("path");
18
19
  const { minimatch } = require("minimatch");
19
20
 
@@ -21,7 +22,9 @@ const { minimatch } = require("minimatch");
21
22
  * Compile glob strings into (relPath) → bool matchers. Each glob prunes both the
22
23
  * matched directory itself and its whole subtree, so `packages/legacy/**` (or the
23
24
  * bare `packages/legacy`) stops the walk at `packages/legacy` — a manifest sitting
24
- * directly in it is never collected.
25
+ * directly in it is never collected. Each matcher carries `.glob` (the original
26
+ * trimmed pattern) so a caller can report WHICH glob matched (see makeDirFilter's
27
+ * onSkip / collectExcludedDirs).
25
28
  */
26
29
  function compileGlobs(globs) {
27
30
  return (globs || []).filter(Boolean).map(String).map(g => g.trim()).filter(Boolean).map(g => {
@@ -31,28 +34,104 @@ function compileGlobs(globs) {
31
34
  const patterns = base.endsWith("/**")
32
35
  ? [base, base.slice(0, -3).replace(/\/+$/, "")] // dir + its subtree
33
36
  : [base, base + "/**"];
34
- return rel => patterns.some(p => p && minimatch(rel, p, { dot: true }));
37
+ const fn = rel => patterns.some(p => p && minimatch(rel, p, { dot: true }));
38
+ fn.glob = g;
39
+ return fn;
35
40
  });
36
41
  }
37
42
 
43
+ /**
44
+ * Superset of every walker's default-skip basename set (Maven core.js, detectCodecs,
45
+ * npm parse.js, the composer/go/nuget/pypi/ruby/gradle/binary codecs, retire.js).
46
+ * Used as the canonical "what the prune policy excludes" set for the report's
47
+ * ignored-directories appendix (collectExcludedDirs). Individual walkers still use
48
+ * their OWN narrower sets — this union is purely for reporting which dirs the scan
49
+ * skipped. Keep in sync when a codec adds a SKIP entry.
50
+ */
51
+ const DEFAULT_EXCLUDE_DIRS = new Set([
52
+ // package stores / vendored trees
53
+ "node_modules", "bower_components", "jspm_packages", "vendor", "packages", "site-packages",
54
+ // build output
55
+ "target", "dist", "build", "build-output", "out", "bin", "obj", "coverage", ".next", ".nuxt",
56
+ // language caches / virtualenvs
57
+ "__pycache__", ".venv", "venv", ".tox", ".mypy_cache", ".cache", ".m2", "tmp", "testdata",
58
+ // VCS / IDE / tool metadata
59
+ ".git", ".svn", ".hg", ".idea", ".vscode", ".gradle", ".mvn",
60
+ ]);
61
+
38
62
  /**
39
63
  * Build a skipDir(absChildDir) predicate.
40
64
  * srcRoot scan root the globs are relative to
41
65
  * defaultSkip Set of basenames the walker prunes by default (its own SKIP)
42
66
  * excludePath user glob strings
43
67
  * useDefaults when false, ignore defaultSkip entirely (--no-default-excludes)
68
+ * onSkip optional (absChild, reason) callback fired when a dir is pruned;
69
+ * reason is { type:"default", name } or { type:"exclude-path", glob }.
44
70
  */
45
- function makeDirFilter({ srcRoot, defaultSkip = null, excludePath = [], useDefaults = true } = {}) {
71
+ function makeDirFilter({ srcRoot, defaultSkip = null, excludePath = [], useDefaults = true, onSkip = null } = {}) {
46
72
  const matchers = compileGlobs(excludePath);
47
73
  return function skipDir(absChild) {
48
74
  const name = path.basename(absChild);
49
- if (useDefaults && defaultSkip && defaultSkip.has(name)) return true;
75
+ if (useDefaults && defaultSkip && defaultSkip.has(name)) {
76
+ if (onSkip) onSkip(absChild, { type: "default", name });
77
+ return true;
78
+ }
50
79
  if (matchers.length && srcRoot) {
51
80
  const rel = path.relative(srcRoot, absChild).split(path.sep).join("/");
52
- if (rel && !rel.startsWith("..") && matchers.some(m => m(rel))) return true;
81
+ if (rel && !rel.startsWith("..")) {
82
+ const hit = matchers.find(m => m(rel));
83
+ if (hit) {
84
+ if (onSkip) onSkip(absChild, { type: "exclude-path", glob: hit.glob });
85
+ return true;
86
+ }
87
+ }
53
88
  }
54
89
  return false;
55
90
  };
56
91
  }
57
92
 
58
- module.exports = { makeDirFilter, compileGlobs };
93
+ /**
94
+ * Walk `srcRoot` once and return the ACTUAL directories the scan's prune policy
95
+ * excludes — the report's ignored-directories appendix. Applies the same policy the
96
+ * codec walkers do (DEFAULT_EXCLUDE_DIRS by basename + the user's --exclude-path
97
+ * globs, both honoring --no-default-excludes), records each pruned dir relative to
98
+ * the scan root with WHY it was pruned, and never descends into a pruned dir (so a
99
+ * top-level `node_modules` is reported once, not its thousands of children).
100
+ *
101
+ * Returns [{ dir, type, reason }] sorted by path. `dir` is POSIX-relative to srcRoot.
102
+ */
103
+ function collectExcludedDirs({ srcRoot, excludePath = [], defaultExcludes = true } = {}) {
104
+ if (!srcRoot) return [];
105
+ let root;
106
+ try { root = fs.realpathSync(path.resolve(srcRoot)); } catch { root = path.resolve(srcRoot); }
107
+ const found = [];
108
+ const onSkip = (absChild, reason) => {
109
+ const rel = path.relative(root, absChild).split(path.sep).join("/") || ".";
110
+ found.push(reason.type === "default"
111
+ ? { dir: rel, type: "default", reason: `default-exclude (${reason.name})` }
112
+ : { dir: rel, type: "exclude-path", reason: `--exclude-path (${reason.glob})` });
113
+ };
114
+ const skipDir = makeDirFilter({ srcRoot: root, defaultSkip: DEFAULT_EXCLUDE_DIRS, excludePath, useDefaults: defaultExcludes !== false, onSkip });
115
+ // Iterative DFS. We only ever descend into NON-pruned dirs, so the heavy trees
116
+ // (node_modules, …) are cut at the top — the walk stays bounded by the real
117
+ // source tree. A high guard backstops a pathological symlink-free deep tree.
118
+ const stack = [root];
119
+ let guard = 0;
120
+ const GUARD_MAX = 2_000_000;
121
+ while (stack.length && guard < GUARD_MAX) {
122
+ const dir = stack.pop();
123
+ let entries;
124
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
125
+ for (const e of entries) {
126
+ if (!e.isDirectory()) continue; // isDirectory() is false for symlinks → no loops
127
+ guard++;
128
+ const absChild = path.join(dir, e.name);
129
+ if (skipDir(absChild)) continue; // onSkip recorded it; do NOT descend
130
+ stack.push(absChild);
131
+ }
132
+ }
133
+ found.sort((a, b) => a.dir.localeCompare(b.dir));
134
+ return found;
135
+ }
136
+
137
+ module.exports = { makeDirFilter, compileGlobs, collectExcludedDirs, DEFAULT_EXCLUDE_DIRS };