fad-checker 2.3.0 → 2.3.2

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 CHANGED
@@ -32,6 +32,14 @@ This project adheres to [Semantic Versioning](https://semver.org/).
32
32
  across every ecosystem. Repeatable; also `excludePath: [...]` in `.fad-env.json`,
33
33
  unioned across config layers. **`--no-default-excludes`** walks the normally
34
34
  pruned dirs (`node_modules`, `vendor`, `target`, `.git`, …). New `lib/path-filter.js`.
35
+ - **Ignored-directories appendix (report chapter 11 + JSON `excludedDirs`).** The
36
+ HTML/`.doc` report now ends with an appendix listing the ACTUAL directories the
37
+ scan did not walk — resolved by re-walking `--src` once under the same prune
38
+ policy the codecs use (the default-exclude set at any depth + your
39
+ `--exclude-path` rules), each path shown relative to the scan root and tagged
40
+ with the rule that pruned it (`default` vs `--exclude-path`). Surfaced in the
41
+ findings JSON as `excludedDirs[]` + `summary.excludedDirs`. New
42
+ `collectExcludedDirs()` in `lib/path-filter.js`.
35
43
 
36
44
  ### Changed
37
45
  - **BREAKING:** the persisted-registry store moved from the Maven-only
@@ -40,6 +48,26 @@ This project adheres to [Semantic Versioning](https://semver.org/).
40
48
  the `<ecosystem>=<url>` form (a bare URL is rejected). Re-add any private Maven
41
49
  repos with `--add-repo maven <name> <url>`.
42
50
 
51
+ ### Fixed
52
+ - **retire.js now skips the same dirs as the rest of the scan.** The vendored-JS
53
+ scan walks the tree itself and was handed a bare `--ignore node_modules,…` list,
54
+ which retire `path.resolve()`s against its **own working directory** — so a
55
+ `node_modules` (or `target`/`dist`/…) nested anywhere under `--src` was scanned
56
+ whenever fad-checker ran from a different directory than the source tree. retire
57
+ is now driven by a generated `--ignorefile` anchored to `--src` that prunes the
58
+ default SKIP dirs **at any depth** and honors `--exclude-path` /
59
+ `--no-default-excludes`, matching `lib/path-filter.js`.
60
+ - **Offline NVD enrichment (incl. CWEs) no longer silently dropped.** The NVD cache
61
+ enforces a 7-day TTL and a schema version; offline, a TTL-expired or older-schema
62
+ entry was treated as a miss — and since an air-gapped box can't re-fetch, the CVE
63
+ lost **all** its NVD enrichment (CWE list, CVSS vector, references, CPE configs).
64
+ That was the "offline scan was missing some CWE titles that the online scan had".
65
+ Offline now reads the warmed cache regardless of age/schema (a missing field just
66
+ stays missing — strictly better than dropping everything); online still enforces
67
+ TTL + schema so it re-fetches and upgrades. CWE IDs were already persisted in the
68
+ cache body (`_schema:2`) and travel in `--export-cache`; CWE *titles* come from the
69
+ bundled `data/cwe-names.json` (identical online/offline).
70
+
43
71
  ## [2.1.0]
44
72
 
45
73
  ### Added
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/fad-checker.svg)](https://www.npmjs.com/package/fad-checker)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/fad-checker.svg)](https://www.npmjs.com/package/fad-checker)
5
- [![license](https://img.shields.io/npm/l/fad-checker.svg)](https://github.com/n8tz/fad-checker/blob/main/package.json)
5
+ [![license](https://img.shields.io/npm/l/fad-checker.svg)](https://github.com/9pings/fad-checker/blob/main/package.json)
6
6
  [![node](https://img.shields.io/node/v/fad-checker.svg)](https://nodejs.org)
7
7
 
8
8
  > **F**abulous **A**utonomous **D**ependency **C**hecker<br>
@@ -10,7 +10,10 @@
10
10
 
11
11
  `fad-checker` audits **Maven · Gradle · npm · Yarn · pnpm · Composer · PyPI · NuGet · Go · Ruby**, vendored JavaScript and committed native binaries in any source tree — multi-module, monorepo, polyglot — and produces a self-contained **HTML + Word report** (CVE prioritised by EPSS + CISA KEV, EOL, obsolete, outdated, licenses) plus **CycloneDX SBOM / CSAF VEX / SARIF / JSON** exports. **No build tools, no Docker, no network needed** — it reads lockfiles and manifests straight off disk.
12
12
 
13
- 🌐 **[Project site & docs →](https://n8tz.github.io/fad-checker/)**
13
+ 🌐 **[Project site & docs →](https://9pings.github.io/fad-checker/)**
14
+
15
+ > [!WARNING]
16
+ > **Young project — expect rough edges.** fad-checker is new and under active development, so it may still contain bugs (including false positives and false negatives). Treat its output as a strong first pass, **double-check anything critical**, and please [report issues](https://github.com/9pings/fad-checker/issues) — they get fixed fast.
14
17
 
15
18
  <p align="center"><img src="docs/assets/demo.gif" alt="fad-checker animated terminal demo — a [n/N] checklist warming each vulnerability database, then CVE findings coloured by severity with KEV badges" height="600"></p>
16
19
 
package/fad-checker.js CHANGED
@@ -495,7 +495,7 @@ async function timedPhase(label, fn) {
495
495
  // (still honor an explicit --report-<type> if a user really wants a path-free one).
496
496
  const anyReportRequested = [options.reportHtml, options.reportDoc, options.reportSbom, options.reportCsaf, options.reportJson, options.reportSarif].some(v => v !== undefined);
497
497
  if (!anyReportRequested) options.report = false;
498
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, regMap, collectWarnings: [] });
498
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, regMap, collectWarnings: [], walkOpts });
499
499
  return;
500
500
  }
501
501
 
@@ -716,7 +716,7 @@ async function timedPhase(label, fn) {
716
716
  // The scan always runs — it feeds the terminal summary, the file outputs and the
717
717
  // CI gate. Which files get written is decided by the --report-* family inside
718
718
  // (HTML + .doc by default; --no-report writes nothing).
719
- await runReportFlow(resolved, { activeIds, runMaven, runGradle, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store, gradlePlatformBoms: gradleCtx?.platformBoms || [], parsedManifests });
719
+ await runReportFlow(resolved, { activeIds, runMaven, runGradle, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store, gradlePlatformBoms: gradleCtx?.platformBoms || [], parsedManifests, walkOpts });
720
720
  if (!readOnly) {
721
721
  ui.section("Next step");
722
722
  ui.info(`run Snyk on the cleaned tree:`);
@@ -725,7 +725,8 @@ async function timedPhase(label, fn) {
725
725
  })();
726
726
 
727
727
  async function runReportFlow(resolved, ecoFlags = {}) {
728
- const { activeIds = [], runMaven = true, runGradle = false, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null, gradlePlatformBoms = [], parsedManifests = [] } = ecoFlags;
728
+ const { activeIds = [], runMaven = true, runGradle = false, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null, gradlePlatformBoms = [], parsedManifests = [], walkOpts = {} } = ecoFlags;
729
+ const { excludePath = [], defaultExcludes = true } = walkOpts;
729
730
  const registriesFor = eco => regMap[eco] || [];
730
731
  const { expandWithTransitives } = require("./lib/cve-match");
731
732
  const { writeReports, computeStats } = require("./lib/cve-report");
@@ -1045,7 +1046,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1045
1046
  else if (!options.src) { st.skip("no source tree (descriptor import)"); }
1046
1047
  else {
1047
1048
  try {
1048
- const r = await sc.scan(resolved, { src: options.src, verbose, retireRefresh: !!options.retireRefresh, offline });
1049
+ const r = await sc.scan(resolved, { src: options.src, verbose, retireRefresh: !!options.retireRefresh, offline, excludePath, defaultExcludes });
1049
1050
  retireMatches = r.matches || [];
1050
1051
  if (options.vendoredJsInventory !== false) vendoredJsInventory = r.meta?.inventory || [];
1051
1052
  const invN = vendoredJsInventory.length;
@@ -1273,6 +1274,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1273
1274
  : { html: resolveOut("html"), doc: resolveOut("doc"), sbom: resolveOut("sbom"), csaf: resolveOut("csaf"), json: resolveOut("json"), sarif: resolveOut("sarif") };
1274
1275
  const ensureDir = async p => { if (p) await fs.promises.mkdir(path.dirname(path.resolve(p)), { recursive: true }); };
1275
1276
 
1277
+ // Ignored-directories appendix: re-walk --src ONCE under the same prune policy the
1278
+ // codec walkers use (the default-exclude union at any depth + --exclude-path,
1279
+ // anchored to --src) so the report can list exactly which directories the scan
1280
+ // skipped, and why. Only computed when an output that renders it is requested.
1281
+ let excludedDirs = [];
1282
+ if (options.src && (out.html || out.doc || out.json)) {
1283
+ try {
1284
+ const { collectExcludedDirs } = require("./lib/path-filter");
1285
+ excludedDirs = collectExcludedDirs({ srcRoot: options.src, excludePath, defaultExcludes });
1286
+ } catch { /* best effort — never block the report on the appendix walk */ }
1287
+ }
1288
+
1276
1289
  const reportWarnings = [
1277
1290
  ...(suppressedCount ? [{
1278
1291
  type: "suppressed",
@@ -1299,7 +1312,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1299
1312
  await ensureDir(out.html); await ensureDir(out.doc);
1300
1313
  const { htmlPath, docPath } = await writeReports({
1301
1314
  cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches, vendoredJsInventory,
1302
- eolResults, obsoleteResults, outdatedResults, licenseResults,
1315
+ eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs,
1303
1316
  resolvedDeps: resolved, projectInfo, warnings: reportWarnings, parsedManifests,
1304
1317
  htmlPath: out.html, docPath: out.doc,
1305
1318
  });
@@ -1329,7 +1342,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1329
1342
  try {
1330
1343
  const { writeFindings } = require("./lib/json-export");
1331
1344
  await ensureDir(out.json);
1332
- writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version, typosquats }, out.json);
1345
+ writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version, typosquats }, out.json);
1333
1346
  wrote.push(["Findings JSON", out.json]);
1334
1347
  } catch (err) { ui.warn(`JSON export failed: ${err.message}`); }
1335
1348
  }
@@ -129,7 +129,7 @@ module.exports = {
129
129
  const lockGoverned = lockedDirs.has(d) && !inBuildSrc(fp);
130
130
  if (lockGoverned) continue; // lockfile already provided resolved deps for this dir
131
131
  for (const dep of r.deps) addRec(dep, fp);
132
- if (!inBuildSrc(fp)) warnings.push({ type: "gradle-no-lockfile", manifestPath: fp, message: `no gradle.lockfile next to ${path.relative(dir, fp) || path.basename(fp)} — versions resolved best-effort from the build script + version catalog (dynamic/programmatic deps may be missed). Enable Gradle dependency locking for exact coverage.` });
132
+ if (!inBuildSrc(fp)) warnings.push({ type: "no-lockfile", manifestPath: fp, message: `no gradle.lockfile next to ${path.relative(dir, fp) || path.basename(fp)} — versions resolved best-effort from the build script + version catalog (dynamic/programmatic deps may be missed). Enable Gradle dependency locking for exact coverage.` });
133
133
  for (const u of r.unresolved) warnings.push({ type: "unresolved-versions", manifestPath: fp, message: `could not resolve the version variable for ${u.group}:${u.name} (${u.raw}) — excluded from CVE matching` });
134
134
  }
135
135
 
@@ -21,7 +21,7 @@ const retireScanner = {
21
21
  kind: "vendored", // résultats → chapitre vendored-JS (séparé des CVE)
22
22
  async scan(_deps, opts = {}) {
23
23
  const { scanWithRetireFull } = require("../retire");
24
- const { matches, inventory, error } = await scanWithRetireFull(opts.src, { verbose: opts.verbose, force: !!opts.retireRefresh, offline: !!opts.offline });
24
+ const { matches, inventory, error } = await scanWithRetireFull(opts.src, { verbose: opts.verbose, force: !!opts.retireRefresh, offline: !!opts.offline, excludePath: opts.excludePath, defaultExcludes: opts.defaultExcludes });
25
25
  return { matches, meta: { inventory, error } };
26
26
  },
27
27
  };
package/lib/cve-report.js CHANGED
@@ -1188,7 +1188,7 @@ function minorSection(title, contentHtml, opts = {}) {
1188
1188
  }
1189
1189
 
1190
1190
  const WARNING_HEADINGS = {
1191
- "no-lockfile": { icon: "⚠️", title: "JS manifest without lockfile" },
1191
+ "no-lockfile": { icon: "⚠️", title: "Manifest without a lockfile — best-effort (ranges skipped)" },
1192
1192
  "yarn-berry-unsupported": { icon: "⚠️", title: "Yarn 2+/Berry lockfile" },
1193
1193
  "unresolved-versions": { icon: "⚠️", title: "Maven deps without a concrete version — silently skipped, run a real Maven/Snyk scan to complete" },
1194
1194
  "private-libs": { icon: "🔒", title: "Private / internal libraries — not on Maven Central, not scanned" },
@@ -1485,7 +1485,7 @@ function renderLicenseChapter(licenseResults) {
1485
1485
  return intro + blocks;
1486
1486
  }
1487
1487
 
1488
- function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, parsedManifests = [], wrapTables = true }) {
1488
+ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs = [], resolvedDeps, projectInfo, warnings, parsedManifests = [], wrapTables = true }) {
1489
1489
  setRenderCtx({ srcRoot: projectInfo?.src || null });
1490
1490
  // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1491
1491
  cveMatches = mergeMatches(cveMatches || []);
@@ -1567,6 +1567,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1567
1567
  unmanagedTotal: unmanagedInventory.length,
1568
1568
  vendoredJsTotal: vendoredJsInv.length,
1569
1569
  fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
1570
+ excludedTotal: excludedDirs?.length || 0,
1570
1571
  });
1571
1572
 
1572
1573
  // Overview charts row, rendered right under the compact totals.
@@ -1601,6 +1602,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1601
1602
  ${majorSection(`8. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" })}
1602
1603
  ${(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
1604
  ${majorSection(`10. Appendix: Scanned dependency descriptors (${scanned.length})`, renderScannedManifests(scanned), { open: false, id: "chsrc" })}
1605
+ ${(excludedDirs?.length || 0) ? majorSection(`11. Appendix: Ignored directories (${excludedDirs.length})`, renderExcludedDirs(excludedDirs), { open: false, id: "chexcl" }) : ""}
1604
1606
  `;
1605
1607
  return wrapTables ? wrapTablesWithCopyButtons(body) : body;
1606
1608
  }
@@ -1664,7 +1666,7 @@ function pickTopEol(eolResults, n) {
1664
1666
  }).slice(0, n);
1665
1667
  }
1666
1668
 
1667
- function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal, scannedTotal }) {
1669
+ function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal, scannedTotal, excludedTotal }) {
1668
1670
  const entries = [];
1669
1671
  if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
1670
1672
  entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
@@ -1680,6 +1682,7 @@ function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vend
1680
1682
  entries.push({ id: "ch8", label: `8. Fix Recos` });
1681
1683
  if (fpTotal) entries.push({ id: "ch9", label: `9. Likely FP (${fpTotal})` });
1682
1684
  entries.push({ id: "chsrc", label: `10. Scanned descriptors (${scannedTotal || 0})` });
1685
+ if (excludedTotal) entries.push({ id: "chexcl", label: `11. Ignored dirs (${excludedTotal})` });
1683
1686
  return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
1684
1687
  }
1685
1688
 
@@ -1759,6 +1762,20 @@ function renderVendoredJsInventory(inventory, srcRoot) {
1759
1762
  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
1763
  }
1761
1764
 
1765
+ function renderExcludedDirs(excludedDirs) {
1766
+ if (!excludedDirs || !excludedDirs.length) return `<div class="empty">No directories were excluded from the scan.</div>`;
1767
+ const byDefault = excludedDirs.filter(e => e.type === "default").length;
1768
+ const byGlob = excludedDirs.filter(e => e.type === "exclude-path").length;
1769
+ 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>`;
1770
+ const rows = excludedDirs.map(e => {
1771
+ const tag = e.type === "exclude-path"
1772
+ ? pill("--exclude-path", "#b45309")
1773
+ : pill("default", "#6b7280");
1774
+ 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>`;
1775
+ }).join("\n");
1776
+ return intro + `<table><thead><tr><th>Directory (relative to --src)</th><th>Rule</th><th>Matched</th></tr></thead><tbody>${rows}</tbody></table>`;
1777
+ }
1778
+
1762
1779
  function renderFalsePositives(matches) {
1763
1780
  if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
1764
1781
  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 +2237,12 @@ ${WORD_SECTION_PR}
2220
2237
  // Render the HTML and/or Word report. Callers pass explicit target paths;
2221
2238
  // a null/omitted path skips that format (so `--report-html` alone writes only HTML).
2222
2239
  // 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 }) {
2240
+ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests }) {
2224
2241
  if (outputDir) {
2225
2242
  if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
2226
2243
  if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
2227
2244
  }
2228
- const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, parsedManifests };
2245
+ const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, warnings, parsedManifests };
2229
2246
  const written = { htmlPath: null, docPath: null };
2230
2247
  if (htmlPath) {
2231
2248
  await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
@@ -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 = [],
72
72
  } = payload;
73
73
 
74
74
  const { buildInventory } = require("./unmanaged");
@@ -105,6 +105,7 @@ function buildFindings(payload = {}) {
105
105
  suppressed: cveMatches.filter(m => m.suppressed).length,
106
106
  malicious: cveMatches.filter(m => m.malicious && !m.suppressed).length,
107
107
  typosquat: typosquats.length,
108
+ excludedDirs: excludedDirs.length,
108
109
  },
109
110
  cve: cveMatches.map(cveFinding),
110
111
  vendored: retireMatches.map(cveFinding),
@@ -116,6 +117,7 @@ function buildFindings(payload = {}) {
116
117
  embedded,
117
118
  vendoredJs: vendoredJsInventory,
118
119
  typosquat: typosquats,
120
+ excludedDirs,
119
121
  };
120
122
  }
121
123
 
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/osv.js CHANGED
@@ -121,18 +121,32 @@ function severityFromOsv(vuln) {
121
121
 
122
122
  function scoreFromVuln(vuln) { return cvssInfoFromVuln(vuln).score; }
123
123
 
124
- /** Extract the first fix version from OSV affected ranges (semver/ecosystem events). */
125
- function fixVersionFromOsv(vuln, depKey) {
124
+ /**
125
+ * Pick the fix version to recommend from OSV's affected ranges. A multi-branch advisory
126
+ * (e.g. Tomcat fixed in 9.0.118, 10.1.55 AND 11.0.22) lists one `fixed` per branch — the
127
+ * old code returned the FIRST, which for an 11.0.21 dep meant "upgrade to 9.0.118", i.e. a
128
+ * DOWNGRADE. Instead pick the smallest fix strictly ABOVE the current version: that's the
129
+ * next patched release on the dep's own branch. If none is greater (the branch has no
130
+ * published fix, or the dep is already patched), return null rather than suggest a downgrade.
131
+ */
132
+ function fixVersionFromOsv(vuln, depKey, depVersion) {
133
+ const { compareMavenVersions } = require("./maven-version");
134
+ const fixes = [];
126
135
  for (const a of vuln.affected || []) {
127
136
  const name = a.package?.name?.toLowerCase();
128
137
  if (name && name !== depKey.toLowerCase()) continue;
129
138
  for (const r of a.ranges || []) {
130
139
  for (const ev of r.events || []) {
131
- if (ev.fixed) return ev.fixed;
140
+ if (ev.fixed) fixes.push(String(ev.fixed));
132
141
  }
133
142
  }
134
143
  }
135
- return null;
144
+ if (!fixes.length) return null;
145
+ const cmp = (x, y) => { try { return compareMavenVersions(x, y); } catch { return String(x).localeCompare(String(y)); } };
146
+ const sorted = [...fixes].sort(cmp);
147
+ if (!depVersion) return sorted[0]; // no current version to compare against → lowest published fix
148
+ const upgrades = sorted.filter(f => cmp(f, depVersion) > 0);
149
+ return upgrades.length ? upgrades[0] : null;
136
150
  }
137
151
 
138
152
  /** Pick the best CVE id from an OSV vuln (prefer CVE-* aliases over GHSA-*). */
@@ -188,7 +202,7 @@ function vulnToMatch(dep, vuln) {
188
202
  ...(cvss.version ? { cvssVersion: `CVSS:${cvss.version}` } : {}),
189
203
  description,
190
204
  summary,
191
- fixVersion: fixVersionFromOsv(vuln, osvPkgName(dep)),
205
+ fixVersion: fixVersionFromOsv(vuln, osvPkgName(dep), dep.version),
192
206
  ghsa: (vuln.aliases || []).find(a => a.startsWith("GHSA-")) || (vuln.id?.startsWith("GHSA-") ? vuln.id : null),
193
207
  published: vuln.published || null,
194
208
  modified: vuln.modified || null,
@@ -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 };
package/lib/retire.js CHANGED
@@ -142,9 +142,58 @@ async function ensureSignatures({ verbose, offline } = {}) {
142
142
  return !!r.ok;
143
143
  }
144
144
 
145
- // Pure: build the retire CLI argv. `jsRepo` (a local signature file) is added
146
- // when available so retire loads signatures from disk instead of the network.
147
- function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
145
+ // Directories retire never needs to walk. Mirrors the per-walker default SKIP sets
146
+ // the codecs use (lib/path-filter.js / npm parse.js): package stores, build output,
147
+ // IDE/VCS metadata. Matched by BASENAME at any depth (see buildRetireIgnorePatterns).
148
+ const DEFAULT_RETIRE_SKIP_DIRS = [
149
+ "node_modules", "bower_components", "jspm_packages",
150
+ ".git", ".idea", ".vscode", ".gradle", ".mvn",
151
+ "target", "dist", "build", "build-output", "out", "coverage", ".next", ".nuxt",
152
+ ];
153
+
154
+ /**
155
+ * Build the lines for a retire ignorefile, mirroring the rest of the scan's
156
+ * exclude policy (lib/path-filter.js#makeDirFilter) so retire skips the SAME dirs
157
+ * every codec walker prunes.
158
+ *
159
+ * Why an ignorefile and not `--ignore`: retire `path.resolve()`s every `--ignore`
160
+ * entry against ITS OWN cwd (cli.js), so a bare "node_modules" becomes
161
+ * `<cwd>/node_modules` and silently misses `<srcDir>/…/node_modules` whenever fad
162
+ * runs from a different directory than `-s` — and it can never express "this dir
163
+ * at ANY depth". An ignorefile line prefixed with `@` is kept VERBATIM (no resolve)
164
+ * and used as a substring regex, which is the only retire mechanism that can.
165
+ *
166
+ * Two layers, exactly like makeDirFilter:
167
+ * - default skip dirs → `@/<name>/` : segment-bounded substring → matches the dir
168
+ * at ANY depth (the bug: a deeply-nested node_modules), and the surrounding
169
+ * slashes keep lookalike files (`node_modules_shim.js`) from being clipped.
170
+ * Dropped entirely when defaultExcludes === false (--no-default-excludes).
171
+ * - user --exclude-path globs → `@<absSrcDir>/<glob>/` : ANCHORED to the scan
172
+ * root (not any-depth), retire expands `*`→[^/]* and `**`→.* itself.
173
+ *
174
+ * retire walks with POSIX-style paths (its own matchers use `/`), so we emit `/`.
175
+ */
176
+ function buildRetireIgnorePatterns({ srcDir, excludePath = [], defaultExcludes = true } = {}) {
177
+ const lines = [];
178
+ if (defaultExcludes !== false) {
179
+ for (const d of DEFAULT_RETIRE_SKIP_DIRS) lines.push(`@/${d}/`);
180
+ }
181
+ const root = srcDir ? path.resolve(srcDir).split(path.sep).join("/").replace(/\/+$/, "") : "";
182
+ for (const g of excludePath || []) {
183
+ // Normalise like compileGlobs: strip a leading ./ or /, a trailing /, and a
184
+ // trailing /** (the dir's whole subtree is already covered by the segment).
185
+ const base = String(g || "").trim().replace(/^\.?\/+/, "").replace(/\/+$/, "").replace(/\/\*\*$/, "");
186
+ if (!base) continue;
187
+ lines.push(root ? `@${root}/${base}/` : `@/${base}/`);
188
+ }
189
+ return lines;
190
+ }
191
+
192
+ // Pure: build the retire CLI argv. `ignoreFile` (a path-anchored ignorefile, see
193
+ // buildRetireIgnorePatterns) is passed when there's anything to exclude. `jsRepo`
194
+ // (a local signature file) is added when available so retire loads signatures from
195
+ // disk instead of the network.
196
+ function buildRetireArgs({ srcDir, outPath, ignoreFile, jsRepo }) {
148
197
  const args = [
149
198
  // --verbose makes retire list EVERY identified library, not just the
150
199
  // vulnerable ones (its own wording: "by default only vulnerable files are
@@ -154,8 +203,8 @@ function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
154
203
  "--outputformat", "json",
155
204
  "--outputpath", outPath,
156
205
  "--jspath", srcDir,
157
- "--ignore", ignoredDirs,
158
206
  ];
207
+ if (ignoreFile) { args.push("--ignorefile", ignoreFile); }
159
208
  if (jsRepo) { args.push("--jsrepo", jsRepo); }
160
209
  return args;
161
210
  }
@@ -222,11 +271,13 @@ function extractVendoredInventory(raw, srcDir) {
222
271
  * Run retire.js against `srcDir`. Returns the parsed JSON output (an array
223
272
  * of file-level findings) or null if retire is unavailable.
224
273
  *
225
- * `--outputformat json` is the structured form; `--ignore` takes a list of
226
- * dirs to skip; we add the standard suspects to keep runtime bounded.
274
+ * `--outputformat json` is the structured form; a generated `--ignorefile`
275
+ * prunes the same dirs the rest of the scan skips (default SKIP set at any depth
276
+ * + the user's --exclude-path globs, anchored to the scan root) so retire never
277
+ * walks node_modules/target/… or anything the auditor excluded.
227
278
  */
228
279
  async function runRetire(srcDir, opts = {}) {
229
- const { verbose, force, offline } = opts;
280
+ const { verbose, force, offline, excludePath, defaultExcludes } = opts;
230
281
  // Optional diagnostics collector: callers (scanWithRetireFull) read diag.error to
231
282
  // surface a genuine SCAN FAILURE (retire crashed / produced no parseable output)
232
283
  // as a report warning — instead of letting it masquerade as "no vendored JS found".
@@ -252,16 +303,25 @@ async function runRetire(srcDir, opts = {}) {
252
303
  }
253
304
 
254
305
  const launcher = findRetireLauncher();
255
- const ignoredDirs = [
256
- "node_modules", "bower_components", "jspm_packages",
257
- ".git", ".idea", ".vscode", ".gradle", ".mvn",
258
- "target", "dist", "build", "build-output", "out", "coverage", ".next", ".nuxt",
259
- ].join(",");
260
306
 
261
307
  // retire.js refuses to write to /dev/stdout, so we use a real temp file
262
308
  // and read it back. Falls back to stdout if --outputpath is rejected.
263
309
  const tmpOut = path.join(os.tmpdir(), `fad-checker-retire-${process.pid}-${Date.now()}.json`);
264
- const args = buildRetireArgs({ srcDir, outPath: tmpOut, ignoredDirs, jsRepo: haveSig ? RETIRE_SIG_FILE : null });
310
+
311
+ // Pruning policy, written to a temp ignorefile so retire honors it for the tree
312
+ // it ACTUALLY walks (anchored to srcDir, not retire's cwd — the nested
313
+ // node_modules bug) and respects the same --exclude-path the codecs do.
314
+ const ignorePatterns = buildRetireIgnorePatterns({ srcDir, excludePath, defaultExcludes });
315
+ // retire treats a `.json` ignorefile as the structured descriptor form; keep a
316
+ // plain-text extension so the `@`-prefixed regex lines are read as such.
317
+ const tmpIgnore = ignorePatterns.length ? path.join(os.tmpdir(), `fad-checker-retire-ignore-${process.pid}-${Date.now()}.txt`) : null;
318
+ if (tmpIgnore) fs.writeFileSync(tmpIgnore, ignorePatterns.join("\n") + "\n");
319
+ const cleanup = () => {
320
+ try { fs.unlinkSync(tmpOut); } catch { /* best effort */ }
321
+ if (tmpIgnore) { try { fs.unlinkSync(tmpIgnore); } catch { /* best effort */ } }
322
+ };
323
+
324
+ const args = buildRetireArgs({ srcDir, outPath: tmpOut, ignoreFile: tmpIgnore, jsRepo: haveSig ? RETIRE_SIG_FILE : null });
265
325
  if (verbose) console.log(`retire: scanning ${srcDir}…${haveSig ? " (local signatures)" : ""}`);
266
326
 
267
327
  let execErr = null;
@@ -284,7 +344,7 @@ async function runRetire(srcDir, opts = {}) {
284
344
  const reason = retireFailureReason(err.stderr, err.message);
285
345
  diag.error = `retire.js scan failed: ${reason}`;
286
346
  if (verbose) console.warn(`retire: failed to run — ${reason}`);
287
- try { fs.unlinkSync(tmpOut); } catch { /* best effort */ }
347
+ cleanup();
288
348
  return null;
289
349
  }
290
350
  }
@@ -299,7 +359,7 @@ async function runRetire(srcDir, opts = {}) {
299
359
  if (verbose) console.warn(`retire: could not parse output — ${err.message}`);
300
360
  return null;
301
361
  } finally {
302
- try { fs.unlinkSync(tmpOut); } catch { /* best effort */ }
362
+ cleanup();
303
363
  }
304
364
  writeCache(srcDir, parsed);
305
365
  return parsed;
@@ -417,6 +477,8 @@ module.exports = {
417
477
  warmRetireSignatures,
418
478
  ensureSignatures,
419
479
  buildRetireArgs,
480
+ buildRetireIgnorePatterns,
481
+ DEFAULT_RETIRE_SKIP_DIRS,
420
482
  retireFailureReason,
421
483
  readCache,
422
484
  writeCache,
@@ -116,7 +116,7 @@ function buildSarif(matches, opts = {}) {
116
116
  driver: {
117
117
  name: "fad-checker",
118
118
  version: String(toolVersion),
119
- informationUri: "https://github.com/n8tz/fad-checker",
119
+ informationUri: "https://github.com/9pings/fad-checker",
120
120
  rules: [...rulesById.values()],
121
121
  },
122
122
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Scan ALL Maven, Gradle, npm, Yarn, Composer, Python, C#/.NET, Go & Ruby dependencies — plus embedded JARs (fat-jars/war/ear) — in a source tree ONE SHOT without mvn/python/etc — CVE (EPSS/KEV-prioritised), EOL, obsolete, outdated & licenses, with SBOM/CSAF/SARIF/JSON exports, CI gating and fix recos",
5
5
  "keywords": [
6
6
  "sca",
@@ -45,9 +45,9 @@
45
45
  "cli"
46
46
  ],
47
47
  "license": "MIT",
48
- "repository": "https://github.com/n8tz/fad-checker",
49
- "homepage": "https://github.com/n8tz/fad-checker#readme",
50
- "bugs": "https://github.com/n8tz/fad-checker/issues",
48
+ "repository": "https://github.com/9pings/fad-checker",
49
+ "homepage": "https://github.com/9pings/fad-checker#readme",
50
+ "bugs": "https://github.com/9pings/fad-checker/issues",
51
51
  "author": "pp9Ping <pp9Ping@gmail.com>",
52
52
  "maintainers": [
53
53
  "pp9Ping <pp9Ping@gmail.com>"