fad-checker 2.1.2 → 2.2.1

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.
@@ -14,7 +14,7 @@ const R = require("./ruby/parse");
14
14
 
15
15
  const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target", "vendor", "tmp"]);
16
16
 
17
- function findGemfileLocks(dir) {
17
+ function findGemfileLocks(dir, skipDir = (child, name) => SKIP.has(name)) {
18
18
  const found = [];
19
19
  const stack = [dir];
20
20
  while (stack.length) {
@@ -22,12 +22,16 @@ function findGemfileLocks(dir) {
22
22
  let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
23
23
  for (const e of entries) {
24
24
  if (e.isFile() && e.name === "Gemfile.lock") found.push(path.join(cur, e.name));
25
- else if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
25
+ else if (e.isDirectory() && !skipDir(path.join(cur, e.name), e.name)) stack.push(path.join(cur, e.name));
26
26
  }
27
27
  }
28
28
  return found;
29
29
  }
30
30
 
31
+ function dirFilter(dir, opts) {
32
+ return require("../path-filter").makeDirFilter({ srcRoot: opts.srcRoot || dir, defaultSkip: SKIP, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
33
+ }
34
+
31
35
  module.exports = {
32
36
  id: "ruby",
33
37
  label: "Ruby",
@@ -40,7 +44,7 @@ module.exports = {
40
44
  const { deps2Exclude } = opts;
41
45
  const out = new Map();
42
46
  const warnings = [];
43
- for (const fp of findGemfileLocks(dir)) {
47
+ for (const fp of findGemfileLocks(dir, dirFilter(dir, opts))) {
44
48
  try {
45
49
  const { deps } = R.parseGemfileLockFile(fp);
46
50
  for (const d of deps) {
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,8 @@ 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");
16
+ const { buildEmbeddedInventory } = require("./embedded");
15
17
 
16
18
  // CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
17
19
  // back to the raw id (with a "—" placeholder name where the row asks for one).
@@ -934,17 +936,42 @@ function renderRecommendationsForEco(slice, meta) {
934
936
  return blocks.join("");
935
937
  }
936
938
 
939
+ // Where a dependency comes from in the tree. A transitive dep (no defining
940
+ // manifest) carries `via` — the chain of g:a from a root direct dep down to it —
941
+ // recorded by the transitive resolver. Show that chain so an EOL/CVE finding on a
942
+ // "dep of a dep" is traceable to the direct dependency that pulls it in. A direct
943
+ // dep falls back to its defining manifest(s).
944
+ function renderDepOrigin(dep, srcRoot) {
945
+ if (dep.via && dep.via.length) {
946
+ const chain = dep.via.map(c => `<code>${esc(c)}</code>`).join(" → ");
947
+ const tail = `<code>${esc(`${dep.groupId}:${dep.artifactId}`)}</code>`;
948
+ const alt = (dep.viaPaths && dep.viaPaths.length > 1) ? ` <span class="defined-more">+${dep.viaPaths.length - 1} more path${dep.viaPaths.length > 2 ? "s" : ""}</span>` : "";
949
+ return `<div class="defined-in"><span class="defined-label">pulled in via:</span> ${chain} → ${tail}${alt}</div>`;
950
+ }
951
+ return renderDefinedIn(dep, srcRoot);
952
+ }
953
+
954
+ // The source of an EOL verdict: the endoflife.date product queried, and which
955
+ // mapping rule (data/eol-mapping.json) matched the dep to that product.
956
+ function renderEolSource(e) {
957
+ const slug = e.productSlug || "";
958
+ const link = slug ? `endoflife.date/${esc(slug)}` : "endoflife.date";
959
+ const rule = e.via ? `<br><span style="color:#6b7280">matched via ${esc(e.via)} = <code>${esc(e.viaKey || "")}</code></span>` : "";
960
+ return `<span>${link}</span>${rule}`;
961
+ }
962
+
937
963
  function renderEolTable(eolResults) {
938
964
  if (!eolResults?.length) return `<div class="empty">No end-of-life frameworks detected.</div>`;
939
965
  const rows = eolResults.map(e => `<tr>
940
966
  <td class="dep">${esc(e.product)}</td>
941
- <td class="dep">${esc(depDisplayName(e.dep))}<br><span style="color:#6b7280">${esc(e.dep.version || "?")}</span>${renderDefinedIn(e.dep, RENDER_CTX.srcRoot)}</td>
967
+ <td class="dep">${esc(depDisplayName(e.dep))}<br><span style="color:#6b7280">${esc(e.dep.version || "?")}</span>${renderDepOrigin(e.dep, RENDER_CTX.srcRoot)}</td>
942
968
  <td>${esc(e.eol || "")}</td>
943
969
  <td>${esc(e.latest || "")}</td>
970
+ <td class="dep">${renderEolSource(e)}</td>
944
971
  <td class="desc">${esc(e.notes || "")}</td>
945
972
  </tr>`).join("\n");
946
973
  return `<table>
947
- <thead><tr><th>Product</th><th>Dependency</th><th>EOL date</th><th>Latest</th><th>Notes</th></tr></thead>
974
+ <thead><tr><th>Product</th><th>Dependency</th><th>EOL date</th><th>Latest</th><th>Source</th><th>Notes</th></tr></thead>
948
975
  <tbody>${rows}</tbody>
949
976
  </table>`;
950
977
  }
@@ -1337,7 +1364,7 @@ const LICENSE_CAT_ORDER = ["network-copyleft", "strong-copyleft", "proprietary",
1337
1364
 
1338
1365
  function renderLicenseChapter(licenseResults) {
1339
1366
  if (!licenseResults || !licenseResults.assessed?.length) {
1340
- return `<div class="empty">No license data resolved (offline, or registries returned none).</div>`;
1367
+ return `<div class="empty">No license data the license scan is off by default (enable with <code>--licenses</code>); also empty when offline or registries return none.</div>`;
1341
1368
  }
1342
1369
  const { byCategory } = licenseResults;
1343
1370
  const blocks = LICENSE_CAT_ORDER.filter(cat => byCategory[cat]?.length).map(cat => {
@@ -1362,7 +1389,7 @@ function renderLicenseChapter(licenseResults) {
1362
1389
  return intro + blocks;
1363
1390
  }
1364
1391
 
1365
- function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
1392
+ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
1366
1393
  setRenderCtx({ srcRoot: projectInfo?.src || null });
1367
1394
  // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1368
1395
  cveMatches = mergeMatches(cveMatches || []);
@@ -1396,7 +1423,12 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1396
1423
  const licenseContent = renderLicenseChapter(licenseResults);
1397
1424
  const licenseTotal = licenseResults?.assessed?.length || 0;
1398
1425
  const licenseFlagged = licenseResults?.flagged?.length || 0;
1399
- const embeddedContent = renderEmbeddedChapter(embMatchesActive, projectInfo?.src);
1426
+ const embeddedInventory = buildEmbeddedInventory(resolvedDeps || new Map(), embMatchesActive);
1427
+ const embeddedContent = renderEmbeddedChapter(embeddedInventory, embMatchesActive, projectInfo?.src);
1428
+ const unmanagedInventory = buildInventory(resolvedDeps || new Map());
1429
+ const unmanagedContent = renderUnmanagedInventory(unmanagedInventory, projectInfo?.src);
1430
+ const vendoredJsInv = vendoredJsInventory || [];
1431
+ const vendoredJsContent = renderVendoredJsInventory(vendoredJsInv, projectInfo?.src);
1400
1432
  const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP, ...embMatchesFP]);
1401
1433
 
1402
1434
  const toolbar = `<div class="toolbar">
@@ -1428,7 +1460,9 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1428
1460
  outdatedTotal: outdatedResults?.length || 0,
1429
1461
  licenseTotal,
1430
1462
  licenseFlagged,
1431
- embeddedTotal: embStats.total,
1463
+ embeddedTotal: embeddedInventory.length,
1464
+ unmanagedTotal: unmanagedInventory.length,
1465
+ vendoredJsTotal: vendoredJsInv.length,
1432
1466
  fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
1433
1467
  });
1434
1468
 
@@ -1448,7 +1482,9 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1448
1482
 
1449
1483
  ${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
1450
1484
  ${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
1451
- ${embStats.total ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embStats.total})`, embeddedContent, { open: embStats.total <= 50, id: "ch1e" }) : ""}
1485
+ ${embeddedInventory.length ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embeddedInventory.length})`, embeddedContent, { open: embeddedInventory.length <= 50, id: "ch1e" }) : ""}
1486
+ ${unmanagedInventory.length ? majorSection(`1C. Unmanaged / vendored binaries (${unmanagedInventory.length})`, unmanagedContent, { open: unmanagedInventory.length <= 50, id: "ch1c" }) : ""}
1487
+ ${vendoredJsInv.length ? majorSection(`1D. Unmanaged / vendored JavaScript (${vendoredJsInv.length})`, vendoredJsContent, { open: vendoredJsInv.length <= 50, id: "ch1d" }) : ""}
1452
1488
  ${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
1453
1489
  ${majorSection(`3. CVE in dev dependencies (${devStats.total})`, devCveContent, { open: devStats.total > 0 && devStats.total <= 50, id: "ch3" })}
1454
1490
  ${majorSection(`4. End-of-Life Frameworks (${eolResults?.length || 0})`, renderEolTable(eolResults), { id: "ch4" })}
@@ -1493,12 +1529,14 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1493
1529
  return out;
1494
1530
  }
1495
1531
 
1496
- function renderToc({ hasWarnings, prodTotal, embeddedTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
1532
+ function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
1497
1533
  const entries = [];
1498
1534
  if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
1499
1535
  entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
1500
1536
  if (embeddedTotal) entries.push({ id: "ch1e", label: `1B. Embedded (${embeddedTotal})` });
1501
- entries.push({ id: "ch2", label: `2. Vendored JS (${retireTotal})` });
1537
+ if (unmanagedTotal) entries.push({ id: "ch1c", label: `1C. Unmanaged (${unmanagedTotal})` });
1538
+ if (vendoredJsTotal) entries.push({ id: "ch1d", label: `1D. Vendored JS (${vendoredJsTotal})` });
1539
+ entries.push({ id: "ch2", label: `2. Vendored JS vulns (${retireTotal})` });
1502
1540
  entries.push({ id: "ch3", label: `3. Dev CVE (${devTotal})` });
1503
1541
  entries.push({ id: "ch4", label: `4. EOL (${eolTotal})` });
1504
1542
  entries.push({ id: "ch5", label: `5. Obsolete (${obsoleteTotal})` });
@@ -1509,25 +1547,82 @@ function renderToc({ hasWarnings, prodTotal, embeddedTotal, retireTotal, devTota
1509
1547
  return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
1510
1548
  }
1511
1549
 
1512
- // Embedded-binary CVE chapter: group findings by their top-level archive (the
1513
- // part of the manifestPath before the first "!/"), so a fat-jar lists all the
1514
- // vulnerable libraries shaded inside it.
1515
- function renderEmbeddedChapter(matches, srcRoot) {
1516
- if (!matches.length) return `<div class="empty">No CVEs found in embedded binaries.</div>`;
1517
- const intro = `<div class="fp-intro">Maven coordinates discovered inside committed <code>.jar</code>/<code>.war</code>/<code>.ear</code> binaries (vendored libs, fat-jars) — not declared in any <code>pom.xml</code>. Patch by rebuilding/replacing the containing archive.</div>`;
1550
+ // Embedded-binary inventory chapter (1B): every embedded coordinate grouped by its
1551
+ // top-level archive (the part of the manifestPath before the first "!/"), with its
1552
+ // CVE status. Vulnerable coords additionally get the full CVE detail table.
1553
+ function renderEmbeddedChapter(inventory, embeddedMatches, srcRoot) {
1554
+ if (!inventory.length) return `<div class="empty">No embedded JAR/WAR/EAR coordinates found.</div>`;
1555
+ const vulnN = inventory.filter(e => e.vulnCount > 0).length;
1556
+ const intro = `<div class="fp-intro">Maven coordinates discovered inside committed <code>.jar</code>/<code>.war</code>/<code>.ear</code> binaries (vendored libs, fat-jars, shaded uber-jars) — <strong>not declared in any <code>pom.xml</code></strong>. Unmanaged third-party code: unknown provenance and patch story even when not vulnerable. <strong>${vulnN}</strong> of ${inventory.length} carry known CVEs (patch by rebuilding/replacing the containing archive).</div>`;
1557
+ // CVE matches keyed by archive, for the per-archive detail tables.
1558
+ const matchesByArchive = new Map();
1559
+ for (const m of (embeddedMatches || [])) {
1560
+ const top = String((m.dep?.manifestPaths || [])[0] || "(unknown archive)").split("!/")[0];
1561
+ if (!matchesByArchive.has(top)) matchesByArchive.set(top, []);
1562
+ matchesByArchive.get(top).push(m);
1563
+ }
1518
1564
  const byArchive = new Map();
1519
- for (const m of matches) {
1520
- const mp = (m.dep?.manifestPaths || [])[0] || "(unknown archive)";
1521
- const top = String(mp).split("!/")[0];
1522
- if (!byArchive.has(top)) byArchive.set(top, []);
1523
- byArchive.get(top).push(m);
1565
+ for (const e of inventory) {
1566
+ if (!byArchive.has(e.archive)) byArchive.set(e.archive, []);
1567
+ byArchive.get(e.archive).push(e);
1524
1568
  }
1525
- const blocks = [...byArchive.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([archive, list]) =>
1526
- minorSection(`📦 <code class="path">${esc(archive)}</code> (${list.length})`, renderCveTable(list), { open: list.length <= 30 })
1527
- ).join("\n");
1569
+ const blocks = [...byArchive.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([archive, list]) => {
1570
+ const rows = list.map(e => {
1571
+ const coord = `${e.groupId}:${e.artifactId}:${e.version}`;
1572
+ const status = e.vulnCount > 0
1573
+ ? pill(`${e.vulnCount} CVE${e.vulnCount > 1 ? "s" : ""}${e.maxSeverity ? " · " + e.maxSeverity : ""}`, "#b91c1c")
1574
+ : pill("no known CVE", "#15803d");
1575
+ return `<tr><td class="dep"><code>${esc(coord)}</code></td><td>${status}</td></tr>`;
1576
+ }).join("\n");
1577
+ const invTable = `<table><thead><tr><th>Coordinate (groupId:artifactId:version)</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
1578
+ const cveDetail = matchesByArchive.has(archive)
1579
+ ? `<div class="defined-in" style="margin-top:8px"><span class="defined-label">CVE detail:</span></div>` + renderCveTable(matchesByArchive.get(archive))
1580
+ : "";
1581
+ return minorSection(`📦 <code class="path">${esc(archive)}</code> (${list.length})`, invTable + cveDetail, { open: list.length <= 30 });
1582
+ }).join("\n");
1528
1583
  return intro + blocks;
1529
1584
  }
1530
1585
 
1586
+ // Unmanaged native-binary inventory (Part C). Committed .dll/.exe/.so/.dylib that no
1587
+ // package manager governs, identified by checksum (deps.dev + CIRCL). Self-contained
1588
+ // inline styles, matching the report's no-external-CSS philosophy.
1589
+ function pill(text, bg, fg = "#fff") {
1590
+ 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>`;
1591
+ }
1592
+ function renderUnmanagedInventory(inventory, srcRoot) {
1593
+ if (!inventory.length) return `<div class="empty">No committed native binaries found.</div>`;
1594
+ 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>`;
1595
+ const rows = inventory.map(e => {
1596
+ let rel = e.path;
1597
+ if (srcRoot) { try { rel = path.relative(srcRoot, e.path); } catch { /* keep abs */ } }
1598
+ const id = e.identity
1599
+ ? `${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>`
1600
+ : `<span style="color:#6b7280">unknown</span>`;
1601
+ const flags = [
1602
+ e.knownMalicious ? pill("⚠ MALICIOUS", "#7c0008") : null,
1603
+ e.integrity === "pristine" ? pill("pristine", "#166534") : (e.integrity === "known-good" ? pill("known-good", "#15803d") : null),
1604
+ e.shouldBeManaged ? pill("should-be-managed", "#2563eb") : null,
1605
+ e.nameMismatch ? pill("name≠checksum", "#ea580c") : null,
1606
+ e.noOnlineInfo ? pill("not recognised by any source", "#6b7280") : null,
1607
+ ].filter(Boolean).join(" ");
1608
+ 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>`;
1609
+ }).join("\n");
1610
+ 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>`;
1611
+ }
1612
+
1613
+ function renderVendoredJsInventory(inventory, srcRoot) {
1614
+ if (!inventory || !inventory.length) return `<div class="empty">No vendored JavaScript libraries identified.</div>`;
1615
+ const vulnN = inventory.filter(e => e.vulnerable).length;
1616
+ 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>`;
1617
+ const rows = inventory.map(e => {
1618
+ const sevPill = e.vulnerable
1619
+ ? pill(`${e.vulnCount} vuln${e.vulnCount > 1 ? "s" : ""}${e.maxSeverity ? " · " + e.maxSeverity : ""}`, "#b91c1c")
1620
+ : pill("no known vuln", "#15803d");
1621
+ 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>`;
1622
+ }).join("\n");
1623
+ 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>`;
1624
+ }
1625
+
1531
1626
  function renderFalsePositives(matches) {
1532
1627
  if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
1533
1628
  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 +2006,12 @@ ${WORD_SECTION_PR}
1911
2006
  // Render the HTML and/or Word report. Callers pass explicit target paths;
1912
2007
  // a null/omitted path skips that format (so `--report-html` alone writes only HTML).
1913
2008
  // 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 }) {
2009
+ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
1915
2010
  if (outputDir) {
1916
2011
  if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
1917
2012
  if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
1918
2013
  }
1919
- const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
2014
+ const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
1920
2015
  const written = { htmlPath: null, docPath: null };
1921
2016
  if (htmlPath) {
1922
2017
  await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
@@ -1933,6 +2028,7 @@ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retire
1933
2028
 
1934
2029
  module.exports = {
1935
2030
  computeStats,
2031
+ buildEmbeddedInventory,
1936
2032
  generateHtmlReport,
1937
2033
  generateWordReport,
1938
2034
  writeReports,
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,
@@ -0,0 +1,57 @@
1
+ /**
2
+ * lib/embedded.js — inventory of Maven coordinates discovered inside committed
3
+ * .jar/.war/.ear archives (provenance:"embedded"), whether or not they carry a CVE.
4
+ *
5
+ * This is a governance / cyber-hygiene signal: the JAR twin of the native-binary
6
+ * inventory (lib/unmanaged.js → chapter 1C) and the vendored-JS inventory
7
+ * (lib/retire.js → chapter 1D). Code that ships inside a committed binary which no
8
+ * pom.xml declares has unknown provenance and patch story even when not vulnerable.
9
+ *
10
+ * Pure: shared by the HTML report (chapter 1B) and the JSON export so both list the
11
+ * SAME set. CVE counts/severity are cross-referenced from the embedded CVE matches
12
+ * by coordKey.
13
+ */
14
+
15
+ const EMB_SEV_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOWN: 0 };
16
+
17
+ /**
18
+ * @param resolvedDeps Map of all resolved deps (embedded ones carry provenance:"embedded").
19
+ * @param embeddedMatches CVE matches whose dep is embedded (subset of the scan's matches).
20
+ * @returns sorted array of { archive, groupId, artifactId, version, coordKey, manifestPath, vulnCount, maxSeverity }
21
+ */
22
+ function buildEmbeddedInventory(resolvedDeps, embeddedMatches) {
23
+ const cveByCoord = new Map(); // coordKey → { ids:Set, maxSeverity }
24
+ for (const m of (embeddedMatches || [])) {
25
+ const key = m.dep?.coordKey;
26
+ if (!key) continue;
27
+ let e = cveByCoord.get(key);
28
+ if (!e) { e = { ids: new Set(), maxSeverity: null }; cveByCoord.set(key, e); }
29
+ e.ids.add(m.cve?.id);
30
+ const sev = m.cve?.severity || "UNKNOWN";
31
+ if (!e.maxSeverity || (EMB_SEV_RANK[sev] || 0) > (EMB_SEV_RANK[e.maxSeverity] || 0)) e.maxSeverity = sev;
32
+ }
33
+ const out = [];
34
+ for (const dep of (resolvedDeps?.values?.() || [])) {
35
+ if (dep.provenance !== "embedded") continue;
36
+ const manifestPath = (dep.manifestPaths || [])[0] || "(unknown archive)";
37
+ const archive = String(manifestPath).split("!/")[0];
38
+ const cve = cveByCoord.get(dep.coordKey);
39
+ out.push({
40
+ archive,
41
+ groupId: dep.groupId || dep.namespace || "",
42
+ artifactId: dep.artifactId || dep.name || "",
43
+ version: dep.version || "",
44
+ coordKey: dep.coordKey,
45
+ manifestPath,
46
+ vulnCount: cve ? cve.ids.size : 0,
47
+ maxSeverity: cve ? cve.maxSeverity : null,
48
+ });
49
+ }
50
+ out.sort((a, b) =>
51
+ (EMB_SEV_RANK[b.maxSeverity] || 0) - (EMB_SEV_RANK[a.maxSeverity] || 0)
52
+ || String(a.archive).localeCompare(String(b.archive))
53
+ || `${a.groupId}:${a.artifactId}:${a.version}`.localeCompare(`${b.groupId}:${b.artifactId}:${b.version}`));
54
+ return out;
55
+ }
56
+
57
+ module.exports = { buildEmbeddedInventory, EMB_SEV_RANK };
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 };