fad-checker 2.2.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cve-report.js CHANGED
@@ -13,6 +13,7 @@ const path = require("path");
13
13
  const { getCodec, allCodecs, ORDER } = require("./codecs");
14
14
  const { computePriority, sortByPriority } = require("./priority");
15
15
  const { buildInventory } = require("./unmanaged");
16
+ const { buildEmbeddedInventory } = require("./embedded");
16
17
 
17
18
  // CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
18
19
  // back to the raw id (with a "—" placeholder name where the row asks for one).
@@ -935,17 +936,42 @@ function renderRecommendationsForEco(slice, meta) {
935
936
  return blocks.join("");
936
937
  }
937
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
+
938
963
  function renderEolTable(eolResults) {
939
964
  if (!eolResults?.length) return `<div class="empty">No end-of-life frameworks detected.</div>`;
940
965
  const rows = eolResults.map(e => `<tr>
941
966
  <td class="dep">${esc(e.product)}</td>
942
- <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>
943
968
  <td>${esc(e.eol || "")}</td>
944
969
  <td>${esc(e.latest || "")}</td>
970
+ <td class="dep">${renderEolSource(e)}</td>
945
971
  <td class="desc">${esc(e.notes || "")}</td>
946
972
  </tr>`).join("\n");
947
973
  return `<table>
948
- <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>
949
975
  <tbody>${rows}</tbody>
950
976
  </table>`;
951
977
  }
@@ -1338,7 +1364,7 @@ const LICENSE_CAT_ORDER = ["network-copyleft", "strong-copyleft", "proprietary",
1338
1364
 
1339
1365
  function renderLicenseChapter(licenseResults) {
1340
1366
  if (!licenseResults || !licenseResults.assessed?.length) {
1341
- 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>`;
1342
1368
  }
1343
1369
  const { byCategory } = licenseResults;
1344
1370
  const blocks = LICENSE_CAT_ORDER.filter(cat => byCategory[cat]?.length).map(cat => {
@@ -1397,7 +1423,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1397
1423
  const licenseContent = renderLicenseChapter(licenseResults);
1398
1424
  const licenseTotal = licenseResults?.assessed?.length || 0;
1399
1425
  const licenseFlagged = licenseResults?.flagged?.length || 0;
1400
- const embeddedContent = renderEmbeddedChapter(embMatchesActive, projectInfo?.src);
1426
+ const embeddedInventory = buildEmbeddedInventory(resolvedDeps || new Map(), embMatchesActive);
1427
+ const embeddedContent = renderEmbeddedChapter(embeddedInventory, embMatchesActive, projectInfo?.src);
1401
1428
  const unmanagedInventory = buildInventory(resolvedDeps || new Map());
1402
1429
  const unmanagedContent = renderUnmanagedInventory(unmanagedInventory, projectInfo?.src);
1403
1430
  const vendoredJsInv = vendoredJsInventory || [];
@@ -1433,7 +1460,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1433
1460
  outdatedTotal: outdatedResults?.length || 0,
1434
1461
  licenseTotal,
1435
1462
  licenseFlagged,
1436
- embeddedTotal: embStats.total,
1463
+ embeddedTotal: embeddedInventory.length,
1437
1464
  unmanagedTotal: unmanagedInventory.length,
1438
1465
  vendoredJsTotal: vendoredJsInv.length,
1439
1466
  fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
@@ -1455,7 +1482,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1455
1482
 
1456
1483
  ${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
1457
1484
  ${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
1458
- ${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" }) : ""}
1459
1486
  ${unmanagedInventory.length ? majorSection(`1C. Unmanaged / vendored binaries (${unmanagedInventory.length})`, unmanagedContent, { open: unmanagedInventory.length <= 50, id: "ch1c" }) : ""}
1460
1487
  ${vendoredJsInv.length ? majorSection(`1D. Unmanaged / vendored JavaScript (${vendoredJsInv.length})`, vendoredJsContent, { open: vendoredJsInv.length <= 50, id: "ch1d" }) : ""}
1461
1488
  ${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
@@ -1520,22 +1547,39 @@ function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vend
1520
1547
  return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
1521
1548
  }
1522
1549
 
1523
- // Embedded-binary CVE chapter: group findings by their top-level archive (the
1524
- // part of the manifestPath before the first "!/"), so a fat-jar lists all the
1525
- // vulnerable libraries shaded inside it.
1526
- function renderEmbeddedChapter(matches, srcRoot) {
1527
- if (!matches.length) return `<div class="empty">No CVEs found in embedded binaries.</div>`;
1528
- 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
+ }
1529
1564
  const byArchive = new Map();
1530
- for (const m of matches) {
1531
- const mp = (m.dep?.manifestPaths || [])[0] || "(unknown archive)";
1532
- const top = String(mp).split("!/")[0];
1533
- if (!byArchive.has(top)) byArchive.set(top, []);
1534
- 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);
1535
1568
  }
1536
- const blocks = [...byArchive.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([archive, list]) =>
1537
- minorSection(`📦 <code class="path">${esc(archive)}</code> (${list.length})`, renderCveTable(list), { open: list.length <= 30 })
1538
- ).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");
1539
1583
  return intro + blocks;
1540
1584
  }
1541
1585
 
@@ -1984,6 +2028,7 @@ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retire
1984
2028
 
1985
2029
  module.exports = {
1986
2030
  computeStats,
2031
+ buildEmbeddedInventory,
1987
2032
  generateHtmlReport,
1988
2033
  generateWordReport,
1989
2034
  writeReports,
@@ -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 };
@@ -72,6 +72,10 @@ function buildFindings(payload = {}) {
72
72
  const { buildInventory } = require("./unmanaged");
73
73
  const unmanaged = resolvedDeps ? buildInventory(resolvedDeps) : [];
74
74
 
75
+ const { buildEmbeddedInventory } = require("./embedded");
76
+ const embeddedMatches = cveMatches.filter(m => m.dep?.provenance === "embedded");
77
+ const embedded = resolvedDeps ? buildEmbeddedInventory(resolvedDeps, embeddedMatches) : [];
78
+
75
79
  const sevCounts = Object.fromEntries(SEV.map(s => [s, 0]));
76
80
  let kev = 0;
77
81
  for (const m of cveMatches) {
@@ -94,16 +98,18 @@ function buildFindings(payload = {}) {
94
98
  licensesFlagged: licenseResults?.flagged?.length || 0,
95
99
  vendored: retireMatches.length,
96
100
  unmanaged: unmanaged.length,
101
+ embedded: embedded.length,
97
102
  vendoredJs: vendoredJsInventory.length,
98
103
  suppressed: cveMatches.filter(m => m.suppressed).length,
99
104
  },
100
105
  cve: cveMatches.map(cveFinding),
101
106
  vendored: retireMatches.map(cveFinding),
102
- eol: eolResults.map(e => ({ product: e.product, eol: e.eol, dep: depBrief(e.dep) })),
107
+ eol: eolResults.map(e => ({ product: e.product, productSlug: e.productSlug || null, via: e.via || null, viaKey: e.viaKey || null, eol: e.eol, dep: depBrief(e.dep) })),
103
108
  obsolete: obsoleteResults.map(o => ({ reason: o.reason || null, replacement: o.replacement || null, source: o.source || null, dep: depBrief(o.dep) })),
104
109
  outdated: outdatedResults.map(o => ({ latest: o.latest, releaseDate: o.releaseDate || null, dep: depBrief(o.dep) })),
105
110
  licenses: (licenseResults?.assessed || []).map(e => ({ category: e.category, licenses: e.ids.concat(e.raw), source: e.source || null, dep: depBrief(e.dep) })),
106
111
  unmanaged,
112
+ embedded,
107
113
  vendoredJs: vendoredJsInventory,
108
114
  };
109
115
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * lib/manifest-copy.js — when writing a cleaned tree (`-t`), copy every NON-Maven
3
+ * ecosystem manifest / lockfile from the source into the target at the same relative
4
+ * path, so `snyk test --all-projects` on the cleaned tree scans every ecosystem — not
5
+ * just the cleaned Maven POMs (which are written separately by core.rewritePoms).
6
+ *
7
+ * Maven is excluded (its cleaned POMs are the whole point of the rewrite); the binary
8
+ * codec is excluded (its files aren't dependency manifests). node_modules / vendor and
9
+ * friends are pruned so we copy the project's own lockfiles, not vendored ones.
10
+ */
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const { makeDirFilter } = require("./path-filter");
14
+ const { allCodecs } = require("./codecs");
15
+
16
+ // Ecosystems whose manifests/lockfiles Snyk reads off disk.
17
+ const COPY_ECOSYSTEMS = new Set(["npm", "nuget", "composer", "pypi", "go", "ruby"]);
18
+ // Companion files that aren't a codec's primary manifest but Snyk/the build need.
19
+ const COMPANION_FILES = ["Directory.Packages.props", "Directory.Build.props", "nuget.config", "Gemfile", "Pipfile", "go.work", "go.work.sum"];
20
+ // Directories never worth copying manifests from (installed/vendored trees).
21
+ const SKIP_DIRS = new Set([
22
+ "node_modules", "bower_components", "jspm_packages", "vendor",
23
+ ".git", ".svn", ".hg", ".idea", ".vscode", ".gradle", ".mvn",
24
+ "target", "dist", "build", "out", "bin", "obj", ".next", ".nuxt", "coverage",
25
+ ]);
26
+
27
+ /** Pure: the exact filenames + extensions that count as a copyable manifest. */
28
+ function manifestMatchers() {
29
+ const exact = new Set(COMPANION_FILES);
30
+ const exts = [];
31
+ for (const c of allCodecs()) {
32
+ if (!COPY_ECOSYSTEMS.has(c.id)) continue;
33
+ for (const n of c.manifestNames || []) {
34
+ if (n.startsWith("*.")) exts.push(n.slice(1)); // "*.csproj" → ".csproj"
35
+ else exact.add(n);
36
+ }
37
+ }
38
+ return { exact, exts };
39
+ }
40
+
41
+ /** Pure: is this basename a manifest we copy? */
42
+ function isManifestName(name, matchers = manifestMatchers()) {
43
+ return matchers.exact.has(name) || matchers.exts.some(e => name.endsWith(e));
44
+ }
45
+
46
+ /**
47
+ * Copy every matching manifest from srcRoot into targetRoot at the same relative path.
48
+ * @returns { copied: number, files: string[] } (files are relative paths)
49
+ */
50
+ async function copyEcosystemManifests(srcRoot, targetRoot, opts = {}) {
51
+ const matchers = manifestMatchers();
52
+ const skipDir = makeDirFilter({ srcRoot, defaultSkip: SKIP_DIRS, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
53
+ const files = [];
54
+ const walk = async (dir) => {
55
+ let entries;
56
+ try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; }
57
+ for (const e of entries) {
58
+ const abs = path.join(dir, e.name);
59
+ if (e.isDirectory()) { if (!skipDir(abs, e.name)) await walk(abs); continue; }
60
+ if (!e.isFile()) continue;
61
+ if (!isManifestName(e.name, matchers)) continue;
62
+ const rel = path.relative(srcRoot, abs);
63
+ const dest = path.join(targetRoot, rel);
64
+ try {
65
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
66
+ await fs.promises.copyFile(abs, dest);
67
+ files.push(rel);
68
+ } catch { /* best effort per file */ }
69
+ }
70
+ };
71
+ await walk(srcRoot);
72
+ return { copied: files.length, files };
73
+ }
74
+
75
+ module.exports = { manifestMatchers, isManifestName, copyEcosystemManifests, COPY_ECOSYSTEMS };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * lib/maven-bom.js — resolve EXTERNAL import-scope BOMs (e.g. spring-boot-dependencies)
3
+ * from Maven Central and backfill the versions of declared deps that the BOM manages.
4
+ *
5
+ * core.js follows LOCAL `<scope>import</scope>` BOMs (their POM is in the source tree)
6
+ * but cannot enumerate an external BOM's managed-version table without fetching it. So
7
+ * in a typical Spring Boot project every `spring-boot-starter-*` (declared without a
8
+ * version, version pinned by the imported BOM) ends up unresolved — flooding chapter 0
9
+ * and dropping out of the CVE/EOL/outdated scans.
10
+ *
11
+ * This module fetches each external import BOM via transitive.js#effectivePom (which
12
+ * already merges the parent chain, resolves `${properties}` and recursively expands
13
+ * nested import BOMs), builds a g:a → version map, and fills it into the versionless
14
+ * declared deps. Network + cached (poms-cache, immutable) + offline-aware (the caller
15
+ * skips it offline). Pure except for the injected/real effectivePom fetch.
16
+ */
17
+ const transitive = require("./transitive");
18
+
19
+ /**
20
+ * Extract the distinct external import-BOM coordinates from the parsed poms' merged
21
+ * dependencyManagement. `version` is resolved against each pom's property map; entries
22
+ * that stay unresolved (`${…}`) or lack a g/a are skipped.
23
+ * @param propsByPom map pomPath → { properties, dependencyManagement } (xml2js-shaped)
24
+ * @returns [{ groupId, artifactId, version }] deduped by g:a:v
25
+ */
26
+ function collectImportBoms(propsByPom) {
27
+ const seen = new Set();
28
+ const out = [];
29
+ for (const pom of Object.keys(propsByPom || {})) {
30
+ const entry = propsByPom[pom];
31
+ if (!entry) continue;
32
+ const props = entry.properties || {};
33
+ for (const d of (entry.dependencyManagement || [])) {
34
+ if (d?.scope?.[0] !== "import") continue;
35
+ const g = transitive.resolveProps(d.groupId?.[0], props);
36
+ const a = transitive.resolveProps(d.artifactId?.[0], props);
37
+ const v = transitive.resolveProps(d.version?.[0], props);
38
+ if (!g || !a || !v || /\$\{/.test(String(v))) continue;
39
+ const k = `${g}:${a}:${v}`;
40
+ if (seen.has(k)) continue;
41
+ seen.add(k);
42
+ out.push({ groupId: g, artifactId: a, version: v });
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * Resolve each import BOM to its managed-version table and merge them into one map.
50
+ * First BOM to manage a given g:a wins (mirrors Maven's declaration-order precedence).
51
+ */
52
+ async function resolveBomManagedVersions(boms, opts = {}) {
53
+ const effectivePom = opts.effectivePom || transitive.effectivePom;
54
+ const map = new Map();
55
+ for (const bom of boms) {
56
+ let eff = null;
57
+ try { eff = await effectivePom(bom.groupId, bom.artifactId, bom.version, opts); }
58
+ catch { eff = null; }
59
+ if (!eff || !eff.depMgmt) continue;
60
+ for (const d of eff.depMgmt) {
61
+ if (!d.groupId || !d.artifactId) continue;
62
+ const k = `${d.groupId}:${d.artifactId}`;
63
+ if (map.has(k)) continue;
64
+ if (d.version && !/\$\{/.test(String(d.version))) map.set(k, String(d.version));
65
+ }
66
+ }
67
+ return map;
68
+ }
69
+
70
+ /**
71
+ * Fill the version of every Maven dep that has no concrete version (null or `${…}`)
72
+ * from the BOM-managed map. Mutates the resolvedDeps Map entries in place.
73
+ * @returns number of deps filled
74
+ */
75
+ function backfillVersions(resolvedDeps, mgmtMap) {
76
+ let filled = 0;
77
+ for (const dep of resolvedDeps.values()) {
78
+ if (dep.ecosystem !== "maven") continue;
79
+ if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
80
+ if (dep.version && !/\$\{/.test(String(dep.version))) continue; // already concrete
81
+ const v = mgmtMap.get(`${dep.groupId}:${dep.artifactId}`);
82
+ if (!v) continue;
83
+ dep.version = v;
84
+ if (!Array.isArray(dep.versions)) dep.versions = [];
85
+ if (!dep.versions.includes(v)) dep.versions.push(v);
86
+ filled++;
87
+ }
88
+ return filled;
89
+ }
90
+
91
+ /**
92
+ * One-shot: collect external import BOMs from the poms, resolve their managed versions
93
+ * online, and backfill the versionless declared deps.
94
+ * @returns { boms, filled, mgmtSize }
95
+ */
96
+ async function resolveAndBackfill(propsByPom, resolvedDeps, opts = {}) {
97
+ const boms = collectImportBoms(propsByPom);
98
+ if (!boms.length) return { boms: 0, filled: 0, mgmtSize: 0 };
99
+ const map = await resolveBomManagedVersions(boms, opts);
100
+ const filled = backfillVersions(resolvedDeps, map);
101
+ return { boms: boms.length, filled, mgmtSize: map.size };
102
+ }
103
+
104
+ module.exports = { collectImportBoms, resolveBomManagedVersions, backfillVersions, resolveAndBackfill };
package/lib/outdated.js CHANGED
@@ -42,6 +42,13 @@ function isEolCacheFresh(maxAge = EOL_CACHE_MAX_AGE_MS) {
42
42
 
43
43
  // -------- EOL via endoflife.date --------
44
44
 
45
+ // Tag the matched mapping entry with HOW it matched (which rule + which key), so
46
+ // the report can show where an EOL verdict comes from. Returns a COPY — the shared
47
+ // EOL_MAPPING objects must never be mutated (they're reused across deps).
48
+ function withOrigin(entry, via, viaKey) {
49
+ return entry ? { ...entry, via, viaKey } : null;
50
+ }
51
+
45
52
  function findEolProduct(dep) {
46
53
  // npm packages — and WebJars, which are client-side JS shipped as Maven
47
54
  // artifacts — resolve by JS library name, not Maven coordinate. npm deps
@@ -49,36 +56,39 @@ function findEolProduct(dep) {
49
56
  // first (org.webjars:angularjs → "angularjs", org.webjars.npm:angular__core
50
57
  // → "@angular/core"). The npm package literally named "angular" is AngularJS
51
58
  // 1.x; modern Angular is the @angular/* scope — hence the name vs. scope maps.
59
+ const isWebjar = dep.ecosystem !== "npm" && webjarToNpm(dep) != null;
52
60
  const npmName = dep.ecosystem === "npm" ? (dep.artifactId || "") : webjarToNpm(dep)?.name;
53
61
  if (npmName != null) {
54
62
  const byName = EOL_MAPPING.by_npm_name?.[npmName];
55
- if (byName) return byName;
63
+ if (byName) return withOrigin(byName, isWebjar ? "webjar" : "npm-name", npmName);
56
64
  const scopes = Object.keys(EOL_MAPPING.by_npm_scope || {})
57
65
  .sort((a, b) => b.length - a.length);
58
66
  for (const s of scopes) {
59
- if (npmName.startsWith(s)) return EOL_MAPPING.by_npm_scope[s];
67
+ if (npmName.startsWith(s)) return withOrigin(EOL_MAPPING.by_npm_scope[s], isWebjar ? "webjar" : "npm-scope", s);
60
68
  }
61
69
  return null;
62
70
  }
63
71
  if (dep.ecosystem === "composer") {
64
72
  const full = `${dep.namespace || dep.groupId || ""}/${dep.name || dep.artifactId}`.toLowerCase();
65
- return EOL_MAPPING.by_composer_name?.[full] || null;
73
+ return withOrigin(EOL_MAPPING.by_composer_name?.[full], "composer-name", full);
66
74
  }
67
75
  if (dep.ecosystem === "pypi") {
68
- return EOL_MAPPING.by_pypi_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
76
+ const k = (dep.name || dep.artifactId || "").toLowerCase();
77
+ return withOrigin(EOL_MAPPING.by_pypi_name?.[k], "pypi-name", k);
69
78
  }
70
79
  if (dep.ecosystem === "nuget") {
71
- return EOL_MAPPING.by_nuget_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
80
+ const k = (dep.name || dep.artifactId || "").toLowerCase();
81
+ return withOrigin(EOL_MAPPING.by_nuget_name?.[k], "nuget-name", k);
72
82
  }
73
83
  const key = `${dep.groupId}:${dep.artifactId}`;
74
84
  const direct = EOL_MAPPING.by_group_artifact?.[key];
75
- if (direct) return direct;
85
+ if (direct) return withOrigin(direct, "group-artifact", key);
76
86
  // Match longest groupId prefix
77
87
  const prefixes = Object.keys(EOL_MAPPING.by_group_prefix || {})
78
88
  .sort((a, b) => b.length - a.length);
79
89
  for (const p of prefixes) {
80
90
  if (dep.groupId === p || dep.groupId.startsWith(p + ".")) {
81
- return EOL_MAPPING.by_group_prefix[p];
91
+ return withOrigin(EOL_MAPPING.by_group_prefix[p], "group-prefix", p);
82
92
  }
83
93
  }
84
94
  return null;
@@ -154,6 +164,8 @@ async function checkEolDeps(resolvedDeps, opts = {}) {
154
164
  dep,
155
165
  product: product.label || product.product,
156
166
  productSlug: product.product,
167
+ via: product.via || null,
168
+ viaKey: product.viaKey || null,
157
169
  cycle: cycle.cycle,
158
170
  eol: cycle.eol === true ? "true" : String(cycle.eol),
159
171
  latest: cycle.latest || null,