fad-checker 2.1.1 → 2.2.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +100 -32
  3. package/fad-checker-report/cve-report.doc +643 -105
  4. package/fad-checker-report/cve-report.html +653 -108
  5. package/fad-checker.js +265 -55
  6. package/lib/codecs/binary/scan.js +71 -0
  7. package/lib/codecs/binary/sniff.js +37 -0
  8. package/lib/codecs/binary.codec.js +54 -0
  9. package/lib/codecs/composer.codec.js +7 -3
  10. package/lib/codecs/go/registry.js +19 -11
  11. package/lib/codecs/go.codec.js +7 -3
  12. package/lib/codecs/index.js +73 -4
  13. package/lib/codecs/maven/jar-scan.js +179 -28
  14. package/lib/codecs/maven.codec.js +4 -2
  15. package/lib/codecs/npm/collect.js +3 -1
  16. package/lib/codecs/npm/parse.js +29 -1
  17. package/lib/codecs/npm/registry.js +29 -18
  18. package/lib/codecs/npm.codec.js +10 -4
  19. package/lib/codecs/nuget.codec.js +7 -3
  20. package/lib/codecs/pypi/registry.js +16 -10
  21. package/lib/codecs/pypi.codec.js +7 -3
  22. package/lib/codecs/recipes.js +9 -1
  23. package/lib/codecs/ruby/registry.js +16 -10
  24. package/lib/codecs/ruby.codec.js +7 -3
  25. package/lib/config.js +34 -23
  26. package/lib/core.js +22 -3
  27. package/lib/cve-match.js +3 -2
  28. package/lib/cve-report.js +59 -6
  29. package/lib/dep-record.js +12 -9
  30. package/lib/hash-id.js +78 -0
  31. package/lib/json-export.js +8 -1
  32. package/lib/license-policy.js +3 -1
  33. package/lib/options-env.js +113 -0
  34. package/lib/outdated.js +3 -0
  35. package/lib/parallel-walk.js +50 -0
  36. package/lib/path-filter.js +58 -0
  37. package/lib/registries.js +112 -0
  38. package/lib/retire.js +66 -0
  39. package/lib/scan-completeness.js +7 -1
  40. package/lib/unmanaged.js +82 -0
  41. package/package.json +1 -1
package/lib/cve-report.js CHANGED
@@ -12,12 +12,15 @@ const fs = require("fs");
12
12
  const path = require("path");
13
13
  const { getCodec, allCodecs, ORDER } = require("./codecs");
14
14
  const { computePriority, sortByPriority } = require("./priority");
15
+ const { buildInventory } = require("./unmanaged");
15
16
 
16
17
  // CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
17
18
  // back to the raw id (with a "—" placeholder name where the row asks for one).
18
19
  const CWE_NAMES = (() => {
19
20
  try {
20
- const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "data", "cwe-names.json"), "utf8"));
21
+ // require() (not fs.readFileSync) so bun --compile bundles the data file
22
+ // into the standalone binary instead of reading it off disk at runtime.
23
+ const raw = { ...require("../data/cwe-names.json") };
21
24
  delete raw._comment;
22
25
  return raw;
23
26
  } catch { return {}; }
@@ -1360,7 +1363,7 @@ function renderLicenseChapter(licenseResults) {
1360
1363
  return intro + blocks;
1361
1364
  }
1362
1365
 
1363
- function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
1366
+ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
1364
1367
  setRenderCtx({ srcRoot: projectInfo?.src || null });
1365
1368
  // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1366
1369
  cveMatches = mergeMatches(cveMatches || []);
@@ -1395,6 +1398,10 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1395
1398
  const licenseTotal = licenseResults?.assessed?.length || 0;
1396
1399
  const licenseFlagged = licenseResults?.flagged?.length || 0;
1397
1400
  const embeddedContent = renderEmbeddedChapter(embMatchesActive, projectInfo?.src);
1401
+ const unmanagedInventory = buildInventory(resolvedDeps || new Map());
1402
+ const unmanagedContent = renderUnmanagedInventory(unmanagedInventory, projectInfo?.src);
1403
+ const vendoredJsInv = vendoredJsInventory || [];
1404
+ const vendoredJsContent = renderVendoredJsInventory(vendoredJsInv, projectInfo?.src);
1398
1405
  const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP, ...embMatchesFP]);
1399
1406
 
1400
1407
  const toolbar = `<div class="toolbar">
@@ -1427,6 +1434,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1427
1434
  licenseTotal,
1428
1435
  licenseFlagged,
1429
1436
  embeddedTotal: embStats.total,
1437
+ unmanagedTotal: unmanagedInventory.length,
1438
+ vendoredJsTotal: vendoredJsInv.length,
1430
1439
  fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
1431
1440
  });
1432
1441
 
@@ -1447,6 +1456,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1447
1456
  ${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
1448
1457
  ${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
1449
1458
  ${embStats.total ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embStats.total})`, embeddedContent, { open: embStats.total <= 50, id: "ch1e" }) : ""}
1459
+ ${unmanagedInventory.length ? majorSection(`1C. Unmanaged / vendored binaries (${unmanagedInventory.length})`, unmanagedContent, { open: unmanagedInventory.length <= 50, id: "ch1c" }) : ""}
1460
+ ${vendoredJsInv.length ? majorSection(`1D. Unmanaged / vendored JavaScript (${vendoredJsInv.length})`, vendoredJsContent, { open: vendoredJsInv.length <= 50, id: "ch1d" }) : ""}
1450
1461
  ${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
1451
1462
  ${majorSection(`3. CVE in dev dependencies (${devStats.total})`, devCveContent, { open: devStats.total > 0 && devStats.total <= 50, id: "ch3" })}
1452
1463
  ${majorSection(`4. End-of-Life Frameworks (${eolResults?.length || 0})`, renderEolTable(eolResults), { id: "ch4" })}
@@ -1491,12 +1502,14 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1491
1502
  return out;
1492
1503
  }
1493
1504
 
1494
- function renderToc({ hasWarnings, prodTotal, embeddedTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
1505
+ function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
1495
1506
  const entries = [];
1496
1507
  if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
1497
1508
  entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
1498
1509
  if (embeddedTotal) entries.push({ id: "ch1e", label: `1B. Embedded (${embeddedTotal})` });
1499
- entries.push({ id: "ch2", label: `2. Vendored JS (${retireTotal})` });
1510
+ if (unmanagedTotal) entries.push({ id: "ch1c", label: `1C. Unmanaged (${unmanagedTotal})` });
1511
+ if (vendoredJsTotal) entries.push({ id: "ch1d", label: `1D. Vendored JS (${vendoredJsTotal})` });
1512
+ entries.push({ id: "ch2", label: `2. Vendored JS vulns (${retireTotal})` });
1500
1513
  entries.push({ id: "ch3", label: `3. Dev CVE (${devTotal})` });
1501
1514
  entries.push({ id: "ch4", label: `4. EOL (${eolTotal})` });
1502
1515
  entries.push({ id: "ch5", label: `5. Obsolete (${obsoleteTotal})` });
@@ -1526,6 +1539,46 @@ function renderEmbeddedChapter(matches, srcRoot) {
1526
1539
  return intro + blocks;
1527
1540
  }
1528
1541
 
1542
+ // Unmanaged native-binary inventory (Part C). Committed .dll/.exe/.so/.dylib that no
1543
+ // package manager governs, identified by checksum (deps.dev + CIRCL). Self-contained
1544
+ // inline styles, matching the report's no-external-CSS philosophy.
1545
+ function pill(text, bg, fg = "#fff") {
1546
+ return `<span style="display:inline-block;padding:2px 8px;border-radius:3px;background:${bg};color:${fg};font-weight:600;font-size:11px">${esc(text)}</span>`;
1547
+ }
1548
+ function renderUnmanagedInventory(inventory, srcRoot) {
1549
+ if (!inventory.length) return `<div class="empty">No committed native binaries found.</div>`;
1550
+ const intro = `<div class="fp-intro">Committed native binaries (<code>.dll</code>/<code>.exe</code>/<code>.so</code>/<code>.dylib</code>) — not governed by any package manager. Identified by checksum (deps.dev + CIRCL). <strong>should-be-managed</strong> = a published package committed as a blob; <strong>name≠checksum</strong> = the filename disagrees with what the hash resolves to; <strong>not recognised by any source</strong> = neither deps.dev nor CIRCL knows this exact file (vendored/custom build — verify its origin manually). Identification is by checksum only; a file absent from both databases stays unidentified even when its name is suggestive (e.g. <code>openssl.exe</code>).</div>`;
1551
+ const rows = inventory.map(e => {
1552
+ let rel = e.path;
1553
+ if (srcRoot) { try { rel = path.relative(srcRoot, e.path); } catch { /* keep abs */ } }
1554
+ const id = e.identity
1555
+ ? `${esc(e.identity.ecosystem ? e.identity.ecosystem + ":" : "")}${esc(e.identity.name || "")}${e.identity.version ? "@" + esc(e.identity.version) : ""} <span style="color:#6b7280">(${esc(e.identity.source || "")})</span>`
1556
+ : `<span style="color:#6b7280">unknown</span>`;
1557
+ const flags = [
1558
+ e.knownMalicious ? pill("⚠ MALICIOUS", "#7c0008") : null,
1559
+ e.integrity === "pristine" ? pill("pristine", "#166534") : (e.integrity === "known-good" ? pill("known-good", "#15803d") : null),
1560
+ e.shouldBeManaged ? pill("should-be-managed", "#2563eb") : null,
1561
+ e.nameMismatch ? pill("name≠checksum", "#ea580c") : null,
1562
+ e.noOnlineInfo ? pill("not recognised by any source", "#6b7280") : null,
1563
+ ].filter(Boolean).join(" ");
1564
+ return `<tr><td class="dep"><code class="path">${esc(rel)}</code></td><td>${id}</td><td>${flags}</td><td><code class="path" style="color:#6b7280">${esc((e.hashes?.sha256 || "").slice(0, 16))}…</code></td></tr>`;
1565
+ }).join("\n");
1566
+ return intro + `<table><thead><tr><th>File</th><th>Identity (by checksum)</th><th>Status</th><th>SHA-256</th></tr></thead><tbody>${rows}</tbody></table>`;
1567
+ }
1568
+
1569
+ function renderVendoredJsInventory(inventory, srcRoot) {
1570
+ if (!inventory || !inventory.length) return `<div class="empty">No vendored JavaScript libraries identified.</div>`;
1571
+ const vulnN = inventory.filter(e => e.vulnerable).length;
1572
+ const intro = `<div class="fp-intro">Standalone JavaScript libraries committed into the tree (jQuery, Bootstrap, PDF.js, …) that <strong>no package manager governs</strong> — unknown provenance, integrity and patch story, identified by signature (retire.js). This is a cyber-hygiene inventory: even a non-vulnerable copy is unmanaged third-party code. <strong>${vulnN}</strong> of ${inventory.length} carry known vulnerabilities (detailed in chapter 2).</div>`;
1573
+ const rows = inventory.map(e => {
1574
+ const sevPill = e.vulnerable
1575
+ ? pill(`${e.vulnCount} vuln${e.vulnCount > 1 ? "s" : ""}${e.maxSeverity ? " · " + e.maxSeverity : ""}`, "#b91c1c")
1576
+ : pill("no known vuln", "#15803d");
1577
+ return `<tr><td>${esc(e.component || "?")}</td><td>${esc(e.version || "?")}</td><td class="dep"><code class="path">${esc(e.file || "")}</code></td><td><span style="color:#6b7280">${esc(e.detection || "")}</span></td><td>${sevPill}</td></tr>`;
1578
+ }).join("\n");
1579
+ 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>`;
1580
+ }
1581
+
1529
1582
  function renderFalsePositives(matches) {
1530
1583
  if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
1531
1584
  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>`;
@@ -1909,12 +1962,12 @@ ${WORD_SECTION_PR}
1909
1962
  // Render the HTML and/or Word report. Callers pass explicit target paths;
1910
1963
  // a null/omitted path skips that format (so `--report-html` alone writes only HTML).
1911
1964
  // Back-compat: `outputDir` still writes both under the default file names.
1912
- async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
1965
+ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
1913
1966
  if (outputDir) {
1914
1967
  if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
1915
1968
  if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
1916
1969
  }
1917
- const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
1970
+ const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
1918
1971
  const written = { htmlPath: null, docPath: null };
1919
1972
  if (htmlPath) {
1920
1973
  await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
package/lib/dep-record.js CHANGED
@@ -35,17 +35,18 @@ function coordKeyFor(ecosystem, namespace, name) {
35
35
  }
36
36
 
37
37
  function makeDepRecord(input) {
38
- const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType, provenance = "manifest" } = input;
38
+ const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType, provenance = "manifest", hashes = null, declaredName = null } = input;
39
39
  const concrete = version && !/\$\{/.test(version) ? version : null;
40
40
  const manifestPaths = manifestPath ? [manifestPath] : [];
41
- // Embedded binaries (a dep discovered INSIDE a .jar/.war/.ear, not declared in a
42
- // manifest) must not share the Map key of a declared dep with the same g:a — else
43
- // they'd merge and the "embedded binaries" report chapter would lose them. Key
44
- // them by their unique physical location instead. provenance stays on the record
45
- // so the report/exports can label & group them; "both" is set later if a merge
46
- // across provenances ever happens.
47
- const coordKey = provenance === "embedded" && manifestPath
48
- ? `embedded:${manifestPath}`
41
+ // Embedded binaries (a dep discovered INSIDE a .jar/.war/.ear) and standalone
42
+ // committed native binaries (.dll/.so/…) must not share the Map key of a declared
43
+ // dep with the same coordinate — they'd merge and the unmanaged report chapter
44
+ // would lose them. Key them by their unique physical location instead. provenance
45
+ // stays on the record so the report/exports can label & group them; "both" is set
46
+ // later if a merge across provenances ever happens.
47
+ const byLocation = (provenance === "embedded" || provenance === "binary") && manifestPath;
48
+ const coordKey = byLocation
49
+ ? `${provenance}:${manifestPath}`
49
50
  : coordKeyFor(ecosystem, namespace, name);
50
51
  return {
51
52
  ecosystem,
@@ -56,6 +57,8 @@ function makeDepRecord(input) {
56
57
  versions: concrete ? [concrete] : [],
57
58
  coordKey,
58
59
  provenance,
60
+ hashes,
61
+ declaredName,
59
62
  scope,
60
63
  isDev: !!isDev,
61
64
  manifestPaths,
package/lib/hash-id.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * lib/hash-id.js — identity-by-checksum for unmanaged artifacts.
3
+ *
4
+ * Two known-good sources, tried in order:
5
+ * 1. deps.dev query-by-hash → exact package coordinate (whole published archive).
6
+ * 2. CIRCL hashlookup → known OS/distro/CDN/NSRL file + free KnownMalicious flag.
7
+ *
8
+ * Cache-backed (~/.fad-checker/hash-id-cache.json, 24h) and --offline-aware: offline
9
+ * reads cache only and never touches the network (project air-gapped principle).
10
+ *
11
+ * @author: N.BRAUN
12
+ * @email: pp9ping@gmail.com
13
+ */
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const os = require("os");
17
+
18
+ const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
19
+ const CACHE_PATH = path.join(CACHE_DIR, "hash-id-cache.json");
20
+ const CACHE_TTL_MS = 24 * 3600 * 1000;
21
+ const DEPSDEV = "https://api.deps.dev/v3/query";
22
+ const CIRCL = "https://hashlookup.circl.lu/lookup/sha256";
23
+
24
+ const SYSTEM_TO_ECO = { MAVEN: "maven", NPM: "npm", NUGET: "nuget", PYPI: "pypi", RUBYGEMS: "ruby", CARGO: "cargo", GO: "go" };
25
+
26
+ function sha1ToBase64(hex) { return Buffer.from(hex, "hex").toString("base64"); }
27
+
28
+ function parseDepsDev(body) {
29
+ const vk = body?.results?.[0]?.version?.versionKey;
30
+ if (!vk?.name) return null;
31
+ return { ecosystem: SYSTEM_TO_ECO[vk.system] || (vk.system || "").toLowerCase() || null, name: vk.name, version: vk.version || null, source: "deps.dev" };
32
+ }
33
+
34
+ function parseCircl(body) {
35
+ if (!body || body.message || !(body.FileName || body.ProductCode)) return null;
36
+ const malicious = Array.isArray(body.KnownMalicious) ? body.KnownMalicious.length > 0 : !!body.KnownMalicious;
37
+ return {
38
+ ecosystem: null,
39
+ name: body.ProductCode?.ProductName || body.FileName || null,
40
+ version: body.ProductCode?.ProductVersion || null,
41
+ source: `circl:${body.db || "hashlookup"}`,
42
+ trust: body["hashlookup:trust"] != null ? body["hashlookup:trust"] : null,
43
+ knownMalicious: malicious,
44
+ };
45
+ }
46
+
47
+ function loadCache() { try { const d = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); if (Date.now() - (d._fetchedAt || 0) < CACHE_TTL_MS) return d.entries || {}; } catch { /* ignore */ } return {}; }
48
+ function saveCache(entries) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify({ _fetchedAt: Date.now(), entries })); } catch { /* ignore */ } }
49
+
50
+ async function lookupHash(hashes, opts = {}) {
51
+ const { fetcher = globalThis.fetch, offline = false, cache } = opts;
52
+ const entries = cache || loadCache();
53
+ const key = hashes.sha256 || hashes.sha1;
54
+ if (!key) return null;
55
+ if (Object.prototype.hasOwnProperty.call(entries, key)) return entries[key];
56
+ if (offline) return null;
57
+
58
+ let identity = null;
59
+ // 1) deps.dev (SHA1 base64)
60
+ if (hashes.sha1) {
61
+ try {
62
+ const r = await fetcher(`${DEPSDEV}?hash.type=SHA1&hash.value=${encodeURIComponent(sha1ToBase64(hashes.sha1))}`, { headers: { "User-Agent": "fad-checker-hashid" } });
63
+ if (r.ok) identity = parseDepsDev(await r.json());
64
+ } catch { /* ignore, try CIRCL */ }
65
+ }
66
+ // 2) CIRCL (SHA-256)
67
+ if (!identity && hashes.sha256) {
68
+ try {
69
+ const r = await fetcher(`${CIRCL}/${hashes.sha256}`, { headers: { "User-Agent": "fad-checker-hashid", Accept: "application/json" } });
70
+ if (r.ok) identity = parseCircl(await r.json());
71
+ } catch { /* ignore */ }
72
+ }
73
+ entries[key] = identity;
74
+ if (!cache) saveCache(entries);
75
+ return identity;
76
+ }
77
+
78
+ module.exports = { sha1ToBase64, parseDepsDev, parseCircl, lookupHash, loadCache, saveCache, CACHE_PATH };
@@ -65,10 +65,13 @@ const SEV = ["critical", "high", "medium", "low", "none", "unknown"];
65
65
  */
66
66
  function buildFindings(payload = {}) {
67
67
  const {
68
- cveMatches = [], retireMatches = [], eolResults = [], obsoleteResults = [],
68
+ cveMatches = [], retireMatches = [], vendoredJsInventory = [], eolResults = [], obsoleteResults = [],
69
69
  outdatedResults = [], licenseResults = null, resolvedDeps, projectInfo = {}, toolVersion = "0",
70
70
  } = payload;
71
71
 
72
+ const { buildInventory } = require("./unmanaged");
73
+ const unmanaged = resolvedDeps ? buildInventory(resolvedDeps) : [];
74
+
72
75
  const sevCounts = Object.fromEntries(SEV.map(s => [s, 0]));
73
76
  let kev = 0;
74
77
  for (const m of cveMatches) {
@@ -90,6 +93,8 @@ function buildFindings(payload = {}) {
90
93
  outdated: outdatedResults.length,
91
94
  licensesFlagged: licenseResults?.flagged?.length || 0,
92
95
  vendored: retireMatches.length,
96
+ unmanaged: unmanaged.length,
97
+ vendoredJs: vendoredJsInventory.length,
93
98
  suppressed: cveMatches.filter(m => m.suppressed).length,
94
99
  },
95
100
  cve: cveMatches.map(cveFinding),
@@ -98,6 +103,8 @@ function buildFindings(payload = {}) {
98
103
  obsolete: obsoleteResults.map(o => ({ reason: o.reason || null, replacement: o.replacement || null, source: o.source || null, dep: depBrief(o.dep) })),
99
104
  outdated: outdatedResults.map(o => ({ latest: o.latest, releaseDate: o.releaseDate || null, dep: depBrief(o.dep) })),
100
105
  licenses: (licenseResults?.assessed || []).map(e => ({ category: e.category, licenses: e.ids.concat(e.raw), source: e.source || null, dep: depBrief(e.dep) })),
106
+ unmanaged,
107
+ vendoredJs: vendoredJsInventory,
101
108
  };
102
109
  }
103
110
 
@@ -14,7 +14,9 @@ const path = require("path");
14
14
 
15
15
  const POLICY = (() => {
16
16
  try {
17
- const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "data", "license-policy.json"), "utf8"));
17
+ // require() (not fs.readFileSync) so bun --compile bundles the data file
18
+ // into the standalone binary instead of reading it off disk at runtime.
19
+ const raw = require("../data/license-policy.json");
18
20
  return { categories: raw.categories || {}, aliases: raw.aliases || {} };
19
21
  } catch { return { categories: {}, aliases: {} }; }
20
22
  })();
@@ -0,0 +1,113 @@
1
+ /**
2
+ * lib/options-env.js — layered option resolution.
3
+ *
4
+ * Layers (highest → lowest): CLI flags > config file (--config / ./.fad-env.json,
5
+ * JSON) > FAD_CHECKER_ENV (a CLI-flag string) > global ~/.fad-checker/config.json
6
+ * > commander defaults. Scalar options follow precedence; `registries` are unioned
7
+ * elsewhere (lib/registries.js). The source flag has aliases (src/source).
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
11
+ */
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const { Command } = require("commander");
15
+
16
+ /** Quote/escape-aware shell-ish tokenizer (single+double quotes, backslash). */
17
+ function tokenize(str) {
18
+ const out = [];
19
+ let cur = "", q = null, esc = false, has = false;
20
+ for (const ch of String(str)) {
21
+ if (esc) { cur += ch; esc = false; has = true; continue; }
22
+ if (ch === "\\" && q !== "'") { esc = true; continue; }
23
+ if (q) { if (ch === q) q = null; else cur += ch; has = true; continue; }
24
+ if (ch === '"' || ch === "'") { q = ch; has = true; continue; }
25
+ if (/\s/.test(ch)) { if (has) { out.push(cur); cur = ""; has = false; } continue; }
26
+ cur += ch; has = true;
27
+ }
28
+ if (has) out.push(cur);
29
+ return out;
30
+ }
31
+
32
+ function loadConfigFile(p) {
33
+ const raw = fs.readFileSync(p, "utf8");
34
+ try { return JSON.parse(raw); }
35
+ catch (e) { throw new Error(`invalid JSON in config file ${p}: ${e.message}`); }
36
+ }
37
+
38
+ /** Map `source` → `src` (src wins if both present). Returns a NEW object. */
39
+ function normalizeSource(obj) {
40
+ const o = { ...obj };
41
+ if (o.source != null && o.src == null) o.src = o.source;
42
+ delete o.source;
43
+ return o;
44
+ }
45
+
46
+ /**
47
+ * Parse a CLI-flag string into { options, repos } using a throwaway clone of the
48
+ * real program. Only options whose source !== "default" are returned, so unset
49
+ * flags don't clobber higher layers. `repos` (variadic --repo) returned separately.
50
+ */
51
+ function parseEnvFlags(str, program) {
52
+ const tokens = tokenize(str);
53
+ if (!tokens.length) return { options: {}, repos: [] };
54
+ const clone = new Command();
55
+ clone.exitOverride().allowUnknownOption(true).configureOutput({ writeErr() {}, writeOut() {} });
56
+ for (const o of program.options) clone.addOption(o);
57
+ try { clone.parse(tokens, { from: "user" }); } catch { /* tolerate */ }
58
+ const all = clone.opts();
59
+ const options = {};
60
+ for (const name of Object.keys(all)) {
61
+ const src = clone.getOptionValueSource(name);
62
+ if (src && src !== "default") options[name] = all[name];
63
+ }
64
+ const repos = Array.isArray(options.repo) ? options.repo : [];
65
+ delete options.repo;
66
+ return { options: normalizeSource(options), repos };
67
+ }
68
+
69
+ /** Resolve { fileLayer, envLayer, envRepos }. */
70
+ function loadLayers({ cwd = process.cwd(), configPath = null, envStr = process.env.FAD_CHECKER_ENV, program = null } = {}) {
71
+ let fileLayer = {};
72
+ const chosen = configPath || path.join(cwd, ".fad-env.json");
73
+ if (configPath) fileLayer = loadConfigFile(chosen);
74
+ else if (fs.existsSync(chosen)) fileLayer = loadConfigFile(chosen);
75
+ fileLayer = normalizeSource(fileLayer || {});
76
+ let envLayer = {}, envRepos = [];
77
+ if (envStr && program) {
78
+ // If FAD_CHECKER_ENV points to a readable file, treat its content as flags too.
79
+ let s = envStr;
80
+ try { if (fs.existsSync(envStr) && fs.statSync(envStr).isFile()) s = fs.readFileSync(envStr, "utf8"); } catch { /* inline */ }
81
+ const parsed = parseEnvFlags(s, program);
82
+ envLayer = parsed.options; envRepos = parsed.repos;
83
+ }
84
+ return { fileLayer, envLayer, envRepos };
85
+ }
86
+
87
+ /**
88
+ * Merge layers onto the parsed program. A file/env/global value fills an option
89
+ * ONLY when the CLI did not set it (source default/undefined). Order: file > env
90
+ * > global. Returns the effective options object (a copy of program.opts()).
91
+ */
92
+ function applyLayers(program, layers = {}, globalStore = {}) {
93
+ const eff = normalizeSource(program.opts());
94
+ const fileLayer = normalizeSource(layers.fileLayer || {});
95
+ const envLayer = normalizeSource(layers.envLayer || {});
96
+ const cliSet = name => {
97
+ const s = program.getOptionValueSource(name);
98
+ return s && s !== "default";
99
+ };
100
+ const candidates = new Set([...Object.keys(fileLayer), ...Object.keys(envLayer), ...Object.keys(globalStore || {})]);
101
+ candidates.delete("registries"); // unioned separately
102
+ candidates.delete("excludePath"); // unioned separately
103
+ candidates.delete("source");
104
+ for (const name of candidates) {
105
+ if (cliSet(name)) continue; // CLI wins
106
+ if (name in fileLayer) eff[name] = fileLayer[name];
107
+ else if (name in envLayer) eff[name] = envLayer[name];
108
+ else if (globalStore && name in globalStore) eff[name] = globalStore[name];
109
+ }
110
+ return eff;
111
+ }
112
+
113
+ module.exports = { tokenize, loadConfigFile, normalizeSource, parseEnvFlags, loadLayers, applyLayers };
package/lib/outdated.js CHANGED
@@ -318,4 +318,7 @@ module.exports = {
318
318
  EOL_MAPPING,
319
319
  EOL_CACHE_PATH,
320
320
  KNOWN_OBSOLETE,
321
+ loadJsonCache,
322
+ saveJsonCache,
323
+ CACHE_DIR,
321
324
  };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * lib/parallel-walk.js — bounded-concurrency recursive directory walk.
3
+ *
4
+ * A serial readdirSync walk is fine on a fast local disk, but on a high-latency
5
+ * filesystem (WSL/9p, network mounts, the air-gapped VM in the field) each readdir
6
+ * pays a round-trip, so a big tree takes tens of seconds per walk — and fad-checker
7
+ * walks the tree several times (detection, pom discovery, jar discovery, JS manifest
8
+ * discovery). Issuing many readdirs concurrently turns that latency-bound serial walk
9
+ * into something close to a single round-trip deep, which is dramatically faster.
10
+ *
11
+ * `onDir(absDir, entries)` is called once per visited directory with its Dirent[].
12
+ * `skipDir(absChildDir, name)` prunes a child directory (caller's own skip policy);
13
+ * it receives the child's absolute path (for path-relative globs) plus its basename.
14
+ *
15
+ * @author: N.BRAUN
16
+ * @email: pp9ping@gmail.com
17
+ */
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+
21
+ const DEFAULT_CONCURRENCY = 48;
22
+
23
+ async function walkDirs(root, { skipDir = () => false, concurrency = DEFAULT_CONCURRENCY, onDir } = {}) {
24
+ const queue = [root];
25
+ let active = 0;
26
+ await new Promise(resolve => {
27
+ let settled = false;
28
+ const finish = () => { if (!settled) { settled = true; resolve(); } };
29
+ const pump = () => {
30
+ if (settled) return;
31
+ if (queue.length === 0 && active === 0) return finish();
32
+ while (active < concurrency && queue.length) {
33
+ const cur = queue.pop();
34
+ active++;
35
+ fs.promises.readdir(cur, { withFileTypes: true }).then(entries => {
36
+ if (onDir) onDir(cur, entries);
37
+ for (const e of entries) {
38
+ if (!e.isDirectory()) continue;
39
+ const child = path.join(cur, e.name);
40
+ if (!skipDir(child, e.name)) queue.push(child);
41
+ }
42
+ }).catch(() => { /* unreadable dir → skip, same as readdirSync catch */ })
43
+ .finally(() => { active--; pump(); });
44
+ }
45
+ };
46
+ pump();
47
+ });
48
+ }
49
+
50
+ module.exports = { walkDirs };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * lib/path-filter.js — directory-walk pruning policy shared by every codec walker.
3
+ *
4
+ * Two layers:
5
+ * - default skips: each walker's own basename set (node_modules, vendor, target,
6
+ * .git, …). Bypassable with useDefaults=false (CLI --no-default-excludes).
7
+ * - user globs: --exclude-path / `excludePath` config, matched gitignore-style
8
+ * against the directory's path RELATIVE to the scan root (`srcRoot`). A bare
9
+ * `foo/bar` matches that directory and its whole subtree (`foo/bar/**`).
10
+ *
11
+ * makeDirFilter() returns a predicate over a child directory's ABSOLUTE path, so
12
+ * it drops straight into parallel-walk's skipDir and the serial readdir walkers.
13
+ *
14
+ * @author: N.BRAUN
15
+ * @email: pp9ping@gmail.com
16
+ */
17
+ const path = require("path");
18
+ const { minimatch } = require("minimatch");
19
+
20
+ /**
21
+ * Compile glob strings into (relPath) → bool matchers. Each glob prunes both the
22
+ * matched directory itself and its whole subtree, so `packages/legacy/**` (or the
23
+ * bare `packages/legacy`) stops the walk at `packages/legacy` — a manifest sitting
24
+ * directly in it is never collected.
25
+ */
26
+ function compileGlobs(globs) {
27
+ return (globs || []).filter(Boolean).map(String).map(g => g.trim()).filter(Boolean).map(g => {
28
+ // Paths match relative to srcRoot, so they're already root-anchored. Accept
29
+ // `truc`, `/truc` and `./truc` as the same thing (strip a leading ./ or /).
30
+ const base = g.replace(/^\.?\/+/, "").replace(/\/+$/, "");
31
+ const patterns = base.endsWith("/**")
32
+ ? [base, base.slice(0, -3).replace(/\/+$/, "")] // dir + its subtree
33
+ : [base, base + "/**"];
34
+ return rel => patterns.some(p => p && minimatch(rel, p, { dot: true }));
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Build a skipDir(absChildDir) predicate.
40
+ * srcRoot scan root the globs are relative to
41
+ * defaultSkip Set of basenames the walker prunes by default (its own SKIP)
42
+ * excludePath user glob strings
43
+ * useDefaults when false, ignore defaultSkip entirely (--no-default-excludes)
44
+ */
45
+ function makeDirFilter({ srcRoot, defaultSkip = null, excludePath = [], useDefaults = true } = {}) {
46
+ const matchers = compileGlobs(excludePath);
47
+ return function skipDir(absChild) {
48
+ const name = path.basename(absChild);
49
+ if (useDefaults && defaultSkip && defaultSkip.has(name)) return true;
50
+ if (matchers.length && srcRoot) {
51
+ const rel = path.relative(srcRoot, absChild).split(path.sep).join("/");
52
+ if (rel && !rel.startsWith("..") && matchers.some(m => m(rel))) return true;
53
+ }
54
+ return false;
55
+ };
56
+ }
57
+
58
+ module.exports = { makeDirFilter, compileGlobs };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * lib/registries.js — per-ecosystem registry list assembly + auth + fan-out.
3
+ *
4
+ * Generalizes lib/maven-repo.js's list-building to npm/pypi/ruby/go. Custom
5
+ * registries are tried first (declared order); callers append the public base
6
+ * last via withPublic(). Lists are unioned across config layers, deduped by URL.
7
+ *
8
+ * Entry shape: { name?, url, auth?, token? }
9
+ * auth "user:pass" → Authorization: Basic <base64>
10
+ * token "…" → Authorization: Bearer <token>
11
+ *
12
+ * @author: N.BRAUN
13
+ * @email: pp9ping@gmail.com
14
+ */
15
+ const SUPPORTED = ["maven", "npm", "pypi", "ruby", "go"];
16
+
17
+ const PUBLIC_BASES = {
18
+ maven: "https://repo1.maven.org/maven2/",
19
+ npm: "https://registry.npmjs.org/",
20
+ pypi: "https://pypi.org/pypi/",
21
+ ruby: "https://rubygems.org/api/v1/gems/",
22
+ go: "https://proxy.golang.org/",
23
+ };
24
+
25
+ function normalise(url) {
26
+ if (!url) return url;
27
+ return url.endsWith("/") ? url : url + "/";
28
+ }
29
+
30
+ function splitUrlAuth(url) {
31
+ if (!url) return { url, auth: null };
32
+ try {
33
+ const u = new URL(url);
34
+ if (u.username || u.password) {
35
+ const auth = decodeURIComponent(u.username) + ":" + decodeURIComponent(u.password);
36
+ u.username = ""; u.password = "";
37
+ return { url: u.toString(), auth };
38
+ }
39
+ } catch { /* not a URL */ }
40
+ return { url, auth: null };
41
+ }
42
+
43
+ function authHeaderFor(entry) {
44
+ if (!entry) return null;
45
+ if (entry.token) return "Bearer " + entry.token;
46
+ if (entry.auth) return "Basic " + Buffer.from(entry.auth).toString("base64");
47
+ return null;
48
+ }
49
+
50
+ /** Union of registry entries from several layers (arrays). Dedup by URL, first wins. */
51
+ function buildRegistryList(_ecosystem, layers = []) {
52
+ const out = [];
53
+ const seen = new Set();
54
+ for (const layer of layers) {
55
+ for (const r of layer || []) {
56
+ if (!r?.url) continue;
57
+ const { url, auth } = splitUrlAuth(normalise(r.url));
58
+ if (seen.has(url)) continue;
59
+ seen.add(url);
60
+ out.push({ name: r.name || url, url, auth: r.auth || auth || null, token: r.token || null });
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Append the ecosystem's public base (no auth) as the final fallback. */
67
+ function withPublic(ecosystem, list) {
68
+ const pub = PUBLIC_BASES[ecosystem];
69
+ const out = [...(list || [])];
70
+ if (pub && !out.some(r => normalise(r.url) === pub)) out.push({ name: "public", url: pub, auth: null, token: null });
71
+ return out;
72
+ }
73
+
74
+ /** Merge two registry maps (eco → entries[]) into one (concat per eco). */
75
+ function mergeRegistryMaps(...maps) {
76
+ const out = {};
77
+ for (const m of maps) {
78
+ if (!m || typeof m !== "object") continue;
79
+ for (const eco of Object.keys(m)) {
80
+ if (!Array.isArray(m[eco])) continue;
81
+ out[eco] = (out[eco] || []).concat(m[eco]);
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * Try each base in order; return the first response whose `res.ok` is true,
89
+ * as { res, base, url }. Applies per-base auth. opts.fetcher for tests.
90
+ */
91
+ async function fetchFirstOk(bases, buildUrl, opts = {}) {
92
+ const { fetcher = globalThis.fetch, userAgent = "fad-checker", timeoutMs, onMiss } = opts;
93
+ for (const base of bases) {
94
+ const url = buildUrl(normalise(base.url));
95
+ const headers = { "User-Agent": userAgent, Accept: "application/json" };
96
+ const ah = authHeaderFor(base);
97
+ if (ah) headers.Authorization = ah;
98
+ let res;
99
+ try {
100
+ res = await fetcher(url, { headers, ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}) });
101
+ } catch (err) { if (onMiss) onMiss(base, `network: ${err.message}`); continue; }
102
+ if (res.ok) return { res, base, url };
103
+ if (onMiss) onMiss(base, `HTTP ${res.status}`);
104
+ }
105
+ return null;
106
+ }
107
+
108
+ module.exports = {
109
+ SUPPORTED, PUBLIC_BASES,
110
+ normalise, splitUrlAuth, authHeaderFor,
111
+ buildRegistryList, withPublic, mergeRegistryMaps, fetchFirstOk,
112
+ };