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/CHANGELOG.md +76 -0
- package/README.md +15 -14
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +3 -0
- package/fad-checker.js +122 -12
- package/lib/codecs/composer/registry.js +38 -6
- package/lib/codecs/npm.codec.js +1 -1
- package/lib/codecs/nuget/registry.js +49 -10
- package/lib/cve-report.js +182 -58
- package/lib/diff.js +0 -0
- package/lib/json-export.js +9 -1
- package/lib/nvd.js +14 -3
- package/lib/path-filter.js +85 -6
- package/lib/provenance.js +135 -0
- package/lib/registries.js +6 -1
- package/lib/report-integrity.js +52 -0
- package/lib/retire.js +77 -15
- package/lib/snyk.js +37 -3
- package/package.json +1 -1
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 {
|
|
1181
|
+
const { id } = opts;
|
|
1175
1182
|
const idAttr = id ? ` id="${esc(id)}"` : "";
|
|
1176
|
-
return `<details class="report-section"
|
|
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 {
|
|
1184
|
-
|
|
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
|
-
//
|
|
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
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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
|
-
${
|
|
1592
|
-
${
|
|
1593
|
-
${
|
|
1594
|
-
${
|
|
1595
|
-
${
|
|
1596
|
-
${
|
|
1597
|
-
${
|
|
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
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
package/lib/json-export.js
CHANGED
|
@@ -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
|
};
|
package/lib/path-filter.js
CHANGED
|
@@ -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
|
-
|
|
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))
|
|
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("..")
|
|
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
|
-
|
|
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 };
|