fad-checker 2.1.2 → 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.
package/lib/config.js CHANGED
@@ -44,35 +44,45 @@ function getNvdApiKey() {
44
44
  }
45
45
 
46
46
  /**
47
- * Custom Maven repositories (Nexus, Artifactory, JBoss, …) the user has
48
- * configured. Returned as an array of { name, url, auth? } where `auth` is
49
- * pre-encoded "user:pass" (caller wraps as Basic <base64>).
47
+ * Per-ecosystem custom registries (Nexus/Artifactory/JBoss, Verdaccio, devpi,
48
+ * Gemfury, Athens, …) the user has configured. Stored under config key
49
+ * `registries`: { <ecosystem>: [{ name, url, auth?, token? }] } where `auth` is
50
+ * "user:pass" (→ Basic) and `token` is a Bearer token.
50
51
  *
51
- * Maven Central is intentionally NOT included here — callers append it as
52
- * the final fallback. That keeps the user's repos in priority order while
53
- * always ensuring Central works as a safety net.
52
+ * Public registries (Maven Central, registry.npmjs.org, …) are intentionally
53
+ * NOT stored here — callers append them as the final fallback so the user's
54
+ * registries stay in priority order while the public one is always a safety net.
54
55
  */
55
- function getMavenRepos() {
56
- const list = get("maven_repos") || [];
56
+ function getRegistryMap() {
57
+ const m = get("registries");
58
+ return (m && typeof m === "object" && !Array.isArray(m)) ? m : {};
59
+ }
60
+
61
+ function getRegistries(ecosystem) {
62
+ const list = getRegistryMap()[ecosystem];
57
63
  return Array.isArray(list) ? list : [];
58
64
  }
59
65
 
60
- function setMavenRepos(list) {
61
- return set("maven_repos", Array.isArray(list) && list.length ? list : null);
66
+ function setRegistryMap(map) {
67
+ return set("registries", map && Object.keys(map).length ? map : null);
62
68
  }
63
69
 
64
- function addMavenRepo(name, url, auth = null) {
65
- const list = getMavenRepos().filter(r => r.name !== name);
66
- list.push({ name, url, ...(auth ? { auth } : {}) });
67
- setMavenRepos(list);
70
+ function addRegistry(ecosystem, name, url, { auth = null, token = null } = {}) {
71
+ const map = getRegistryMap();
72
+ const list = (map[ecosystem] || []).filter(r => r.name !== name);
73
+ list.push({ name, url, ...(auth ? { auth } : {}), ...(token ? { token } : {}) });
74
+ map[ecosystem] = list;
75
+ setRegistryMap(map);
68
76
  return list;
69
77
  }
70
78
 
71
- function removeMavenRepo(name) {
72
- const before = getMavenRepos();
73
- const after = before.filter(r => r.name !== name);
74
- setMavenRepos(after);
75
- return before.length !== after.length;
79
+ function removeRegistry(ecosystem, name) {
80
+ const map = getRegistryMap();
81
+ const before = (map[ecosystem] || []).length;
82
+ map[ecosystem] = (map[ecosystem] || []).filter(r => r.name !== name);
83
+ if (!map[ecosystem].length) delete map[ecosystem];
84
+ setRegistryMap(map);
85
+ return before !== (map[ecosystem]?.length || 0);
76
86
  }
77
87
 
78
88
  module.exports = {
@@ -83,8 +93,9 @@ module.exports = {
83
93
  set,
84
94
  get,
85
95
  getNvdApiKey,
86
- getMavenRepos,
87
- setMavenRepos,
88
- addMavenRepo,
89
- removeMavenRepo,
96
+ getRegistryMap,
97
+ getRegistries,
98
+ setRegistryMap,
99
+ addRegistry,
100
+ removeRegistry,
90
101
  };
package/lib/core.js CHANGED
@@ -19,7 +19,9 @@ const SKIP_DIRS = new Set([
19
19
 
20
20
  const coord = v => (v == null ? null : String(v).trim() || null);
21
21
 
22
- function findPomFiles(dir) {
22
+ const defaultPomSkip = child => SKIP_DIRS.has(path.basename(child));
23
+
24
+ function findPomFiles(dir, skipDir = defaultPomSkip) {
23
25
  const out = [];
24
26
  const stack = [dir];
25
27
  while (stack.length) {
@@ -29,8 +31,9 @@ function findPomFiles(dir) {
29
31
  catch { continue; }
30
32
  for (const e of entries) {
31
33
  if (e.isDirectory()) {
32
- if (SKIP_DIRS.has(e.name)) continue;
33
- stack.push(path.join(cur, e.name));
34
+ const child = path.join(cur, e.name);
35
+ if (skipDir(child, e.name)) continue;
36
+ stack.push(child);
34
37
  } else if (e.name === "pom.xml") {
35
38
  out.push(path.join(cur, e.name));
36
39
  }
@@ -41,11 +44,11 @@ function findPomFiles(dir) {
41
44
 
42
45
  // Parallel equivalent of findPomFiles — same result, but readdir runs concurrently
43
46
  // so the walk isn't serialized one round-trip at a time on a high-latency filesystem.
44
- async function findPomFilesAsync(dir) {
47
+ async function findPomFilesAsync(dir, skipDir = defaultPomSkip) {
45
48
  const { walkDirs } = require("./parallel-walk");
46
49
  const out = [];
47
50
  await walkDirs(dir, {
48
- skipDir: name => SKIP_DIRS.has(name),
51
+ skipDir,
49
52
  onDir: (cur, entries) => {
50
53
  for (const e of entries) if (e.isFile() && e.name === "pom.xml") out.push(path.join(cur, e.name));
51
54
  },
@@ -359,6 +362,7 @@ async function rewritePoms(pomPath, allPomMetadata, allPropsByPom, opts) {
359
362
 
360
363
  module.exports = {
361
364
  coord,
365
+ SKIP_DIRS,
362
366
  findPomFiles,
363
367
  findPomFilesAsync,
364
368
  newMetadataStore,
package/lib/cve-match.js CHANGED
@@ -125,14 +125,14 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
125
125
  // Embedded-binary coords don't participate in Maven Central resolution (a
126
126
  // fat-jar already ships its deps, discovered by recursion) and must not act as
127
127
  // a depMgmt override for the declared tree.
128
- if (dep.provenance === "embedded") continue;
128
+ if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
129
129
  if (dep.version && !/\$\{/.test(dep.version)) {
130
130
  rootDepMgmt.set(`${dep.groupId}:${dep.artifactId}`, { version: dep.version });
131
131
  }
132
132
  }
133
133
 
134
134
  const directs = [...resolvedDeps.values()]
135
- .filter(d => d.provenance !== "embedded")
135
+ .filter(d => d.provenance !== "embedded" && d.provenance !== "binary")
136
136
  .filter(d => d.version && !/\$\{/.test(d.version))
137
137
  .filter(d => d.scope !== "test" || opts.includeTestDeps)
138
138
  .filter(d => d.scope !== "parent");
@@ -227,6 +227,7 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
227
227
  // They are caught by the OSV pipeline (which is multi-ecosystem) and
228
228
  // the CPE refinement step (post-NVD).
229
229
  if (dep.ecosystem === "npm") continue;
230
+ if (dep.provenance === "binary") continue; // no resolved coordinate yet (Plan 2 identifies it)
230
231
  const key = `${dep.groupId}:${dep.artifactId}`.toLowerCase();
231
232
  // Tier 1: exact packageName match
232
233
  const t1 = matchOne(dep, byPackage[key], "exact");
package/lib/cve-report.js CHANGED
@@ -12,6 +12,7 @@ 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).
@@ -1362,7 +1363,7 @@ function renderLicenseChapter(licenseResults) {
1362
1363
  return intro + blocks;
1363
1364
  }
1364
1365
 
1365
- 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 }) {
1366
1367
  setRenderCtx({ srcRoot: projectInfo?.src || null });
1367
1368
  // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1368
1369
  cveMatches = mergeMatches(cveMatches || []);
@@ -1397,6 +1398,10 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1397
1398
  const licenseTotal = licenseResults?.assessed?.length || 0;
1398
1399
  const licenseFlagged = licenseResults?.flagged?.length || 0;
1399
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);
1400
1405
  const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP, ...embMatchesFP]);
1401
1406
 
1402
1407
  const toolbar = `<div class="toolbar">
@@ -1429,6 +1434,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1429
1434
  licenseTotal,
1430
1435
  licenseFlagged,
1431
1436
  embeddedTotal: embStats.total,
1437
+ unmanagedTotal: unmanagedInventory.length,
1438
+ vendoredJsTotal: vendoredJsInv.length,
1432
1439
  fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
1433
1440
  });
1434
1441
 
@@ -1449,6 +1456,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1449
1456
  ${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
1450
1457
  ${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
1451
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" }) : ""}
1452
1461
  ${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
1453
1462
  ${majorSection(`3. CVE in dev dependencies (${devStats.total})`, devCveContent, { open: devStats.total > 0 && devStats.total <= 50, id: "ch3" })}
1454
1463
  ${majorSection(`4. End-of-Life Frameworks (${eolResults?.length || 0})`, renderEolTable(eolResults), { id: "ch4" })}
@@ -1493,12 +1502,14 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1493
1502
  return out;
1494
1503
  }
1495
1504
 
1496
- 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 }) {
1497
1506
  const entries = [];
1498
1507
  if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
1499
1508
  entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
1500
1509
  if (embeddedTotal) entries.push({ id: "ch1e", label: `1B. Embedded (${embeddedTotal})` });
1501
- 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})` });
1502
1513
  entries.push({ id: "ch3", label: `3. Dev CVE (${devTotal})` });
1503
1514
  entries.push({ id: "ch4", label: `4. EOL (${eolTotal})` });
1504
1515
  entries.push({ id: "ch5", label: `5. Obsolete (${obsoleteTotal})` });
@@ -1528,6 +1539,46 @@ function renderEmbeddedChapter(matches, srcRoot) {
1528
1539
  return intro + blocks;
1529
1540
  }
1530
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
+
1531
1582
  function renderFalsePositives(matches) {
1532
1583
  if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
1533
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>`;
@@ -1911,12 +1962,12 @@ ${WORD_SECTION_PR}
1911
1962
  // Render the HTML and/or Word report. Callers pass explicit target paths;
1912
1963
  // a null/omitted path skips that format (so `--report-html` alone writes only HTML).
1913
1964
  // Back-compat: `outputDir` still writes both under the default file names.
1914
- 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 }) {
1915
1966
  if (outputDir) {
1916
1967
  if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
1917
1968
  if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
1918
1969
  }
1919
- 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 };
1920
1971
  const written = { htmlPath: null, docPath: null };
1921
1972
  if (htmlPath) {
1922
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
 
@@ -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 };
@@ -9,7 +9,8 @@
9
9
  * into something close to a single round-trip deep, which is dramatically faster.
10
10
  *
11
11
  * `onDir(absDir, entries)` is called once per visited directory with its Dirent[].
12
- * `skipDir(name)` prunes a child directory by basename (caller's own skip policy).
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.
13
14
  *
14
15
  * @author: N.BRAUN
15
16
  * @email: pp9ping@gmail.com
@@ -34,7 +35,9 @@ async function walkDirs(root, { skipDir = () => false, concurrency = DEFAULT_CON
34
35
  fs.promises.readdir(cur, { withFileTypes: true }).then(entries => {
35
36
  if (onDir) onDir(cur, entries);
36
37
  for (const e of entries) {
37
- if (e.isDirectory() && !skipDir(e.name)) queue.push(path.join(cur, e.name));
38
+ if (!e.isDirectory()) continue;
39
+ const child = path.join(cur, e.name);
40
+ if (!skipDir(child, e.name)) queue.push(child);
38
41
  }
39
42
  }).catch(() => { /* unreadable dir → skip, same as readdirSync catch */ })
40
43
  .finally(() => { active--; pump(); });
@@ -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 };