fad-checker 2.2.1 → 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/fad-checker.js CHANGED
@@ -597,6 +597,18 @@ async function timedPhase(label, fn) {
597
597
  }
598
598
  }
599
599
 
600
+ // Mirror every non-Maven lockfile/manifest (npm/yarn/pnpm, composer, pypi, nuget,
601
+ // go, ruby) into the cleaned tree so `snyk test --all-projects` scans the WHOLE
602
+ // polyglot project, not just the cleaned POMs. Maven POMs are the rewrite above.
603
+ let copiedManifests = 0;
604
+ if (!readOnly) {
605
+ try {
606
+ const { copyEcosystemManifests } = require("./lib/manifest-copy");
607
+ const r = await copyEcosystemManifests(options.src, options.target, { excludePath, defaultExcludes });
608
+ copiedManifests = r.copied;
609
+ } catch (err) { console.error(chalk.red(" ✗ manifest copy failed:"), err.message); }
610
+ }
611
+
600
612
  // ---------- Maven POM analysis summary (parents missing / excluded) ----------
601
613
  let privateLibIds = [];
602
614
  if (runMaven && mavenCtx) {
@@ -674,11 +686,15 @@ async function timedPhase(label, fn) {
674
686
  else ui.info(chalk.dim(`${wrotePom} POM(s) cleanable (read-only — pass -t <dir> to write them)`));
675
687
  }
676
688
 
689
+ if (!readOnly && copiedManifests) {
690
+ ui.ok(`${chalk.bold(copiedManifests)} non-Maven lockfile/manifest(s) mirrored → ${chalk.white(options.target)} ${chalk.dim("(so snyk --all-projects scans every ecosystem)")}`);
691
+ }
692
+
677
693
  // ---------- Scan flow (CVE / EOL / Obsolete) ----------
678
694
  // The scan always runs — it feeds the terminal summary, the file outputs and the
679
695
  // CI gate. Which files get written is decided by the --report-* family inside
680
696
  // (HTML + .doc by default; --no-report writes nothing).
681
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings });
697
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store });
682
698
  if (!readOnly) {
683
699
  ui.section("Next step");
684
700
  ui.info(`run Snyk on the cleaned tree:`);
@@ -687,7 +703,7 @@ async function timedPhase(label, fn) {
687
703
  })();
688
704
 
689
705
  async function runReportFlow(resolved, ecoFlags = {}) {
690
- const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [] } = ecoFlags;
706
+ const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null } = ecoFlags;
691
707
  const registriesFor = eco => regMap[eco] || [];
692
708
  const { expandWithTransitives } = require("./lib/cve-match");
693
709
  const { writeReports, computeStats } = require("./lib/cve-report");
@@ -701,13 +717,9 @@ async function runReportFlow(resolved, ecoFlags = {}) {
701
717
  const npmWarnings = collectWarnings || [];
702
718
  let scanWarnings = [];
703
719
  const directCount = resolved.size;
704
-
705
- // Scan-completeness signals: BOMs and unresolved-version deps mean fad-checker
706
- // has gone as far as it can without running Maven/Snyk itself.
707
- if (runMaven) {
708
- const { detectScanCompletenessWarnings } = require("./lib/scan-completeness");
709
- scanWarnings = detectScanCompletenessWarnings(resolved, { ranSnyk: !!options.snyk, ranTransitive: !!options.transitive });
710
- }
720
+ // NOTE: scan-completeness (unresolved-versions) is computed LATER — after the BOM
721
+ // version-resolution step backfills external-BOM-managed versions so it reflects
722
+ // what's *genuinely* still unresolved, not what a Maven Central BOM fetch will fix.
711
723
 
712
724
  // ---- Vulnerability database update (global step progress) ----
713
725
  ui.section("Vulnerability database update");
@@ -726,6 +738,17 @@ async function runReportFlow(resolved, ecoFlags = {}) {
726
738
  const otherRegistryIds = activeIds.filter(id => id !== "maven" && id !== "npm" && id !== "yarn" && getCodec(id)?.checkRegistry);
727
739
  const willCve = !!cveScanner && (!(options.cveOffline || offline) || cveIndexExists);
728
740
  const willTransitive = !!(options.transitive && runMaven);
741
+ // Per-module version mediation overlay: recover transitive versions the global
742
+ // transitive pass masks via cross-module depMgmt bleed. Runs after (and only when)
743
+ // the global pass runs, and needs the parsed store + per-module props.
744
+ const willOverlay = willTransitive && !!mavenPropsByPom && !!mavenStore;
745
+ // External import BOMs (e.g. spring-boot-dependencies): resolve their managed
746
+ // versions to backfill declared deps that pin no version of their own (the usual
747
+ // Spring Boot setup). Cached via poms-cache; offline-aware (uses warmed POMs,
748
+ // never the network — same as transitive resolution).
749
+ const importBoms = (runMaven && mavenPropsByPom)
750
+ ? require("./lib/maven-bom").collectImportBoms(mavenPropsByPom) : [];
751
+ const willBom = importBoms.length > 0;
729
752
  const willOsv = !!options.osv;
730
753
  const willOutdated = !!options.allLibs;
731
754
  const willNvd = !!options.nvd;
@@ -737,9 +760,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
737
760
  const willBinaryId = [...resolved.values()].some(d => d.provenance === "binary");
738
761
  // License detection piggybacks on the registry passes (same fetched metadata),
739
762
  // so it adds no progress step of its own.
740
- const totalSteps = [willTransitive, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
763
+ const totalSteps = [willBom, willTransitive, willOverlay, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
741
764
  const progress = new ui.Progress(totalSteps);
742
765
 
766
+ if (willBom) {
767
+ const st = progress.start("BOM version resolution (Maven Central)");
768
+ try {
769
+ const { resolveAndBackfill } = require("./lib/maven-bom");
770
+ const r = await resolveAndBackfill(mavenPropsByPom, resolved, { repos: mavenRepos, offline, verbose });
771
+ st.done(`${r.filled} dep version(s) from ${r.boms} import BOM(s)`);
772
+ } catch (err) { st.fail(err.message); }
773
+ }
774
+
743
775
  if (willTransitive) {
744
776
  const st = progress.start("Transitive resolution (Maven Central)");
745
777
  await expandWithTransitives(resolved, {
@@ -752,6 +784,32 @@ async function runReportFlow(resolved, ecoFlags = {}) {
752
784
  st.done(`+${resolved.size - directCount} transitive (total ${resolved.size})`);
753
785
  }
754
786
 
787
+ if (willOverlay) {
788
+ const st = progress.start("Per-module version mediation (masked transitives)");
789
+ try {
790
+ const { expandPerModuleOverlay } = require("./lib/version-overlay");
791
+ const ov = await expandPerModuleOverlay(resolved, mavenStore, mavenPropsByPom, {
792
+ verbose,
793
+ offline,
794
+ maxDepth: parseInt(options.transitiveDepth, 10) || 6,
795
+ includeTestDeps: !options.ignoreTest,
796
+ repos: mavenRepos,
797
+ });
798
+ st.done(`+${ov.appended} masked version(s) recovered across ${ov.modules} module(s)`);
799
+ if (verbose && ov.recovered.length) {
800
+ for (const r of ov.recovered) console.log(` ↳ ${r.coord}: +${r.version} (had ${r.had}) via ${r.module}`);
801
+ }
802
+ } catch (err) { st.fail(err.message); }
803
+ }
804
+
805
+ // Scan-completeness signals — computed NOW (after BOM backfill) so only the deps
806
+ // still without a concrete version (external BOM truly unreachable, or offline)
807
+ // are flagged, not the ones we just resolved from spring-boot-dependencies & co.
808
+ if (runMaven) {
809
+ const { detectScanCompletenessWarnings } = require("./lib/scan-completeness");
810
+ scanWarnings = detectScanCompletenessWarnings(resolved, { ranSnyk: !!options.snyk, ranTransitive: !!options.transitive });
811
+ }
812
+
755
813
  // 1. CVE — native scanner contributed by the maven codec (local cvelistV5 index).
756
814
  let cveMatches = [];
757
815
  let cveDataDate = null;
@@ -1078,7 +1136,12 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1078
1136
  ui.warn(`${scanWarnings.length} scan-completeness note(s) — a real Maven/Snyk run may surface more:`);
1079
1137
  for (const w of scanWarnings) {
1080
1138
  ui.info(chalk.dim(`[${w.type}] ${w.message}`));
1081
- for (const it of (w.items || []).slice(0, 4)) console.log(" " + chalk.dim(`· ${it}`));
1139
+ // Items may be plain strings or { id, manifestPaths } objects (the
1140
+ // unresolved-versions warning carries the defining manifest paths).
1141
+ for (const it of (w.items || []).slice(0, 4)) {
1142
+ const id = typeof it === "string" ? it : it.id;
1143
+ console.log(" " + chalk.dim(`· ${id}`));
1144
+ }
1082
1145
  if ((w.items || []).length > 4) console.log(" " + chalk.dim(`· …and ${w.items.length - 4} more`));
1083
1146
  }
1084
1147
  }
package/lib/cve-match.js CHANGED
@@ -180,12 +180,17 @@ function matchOne(dep, entries, confidence) {
180
180
  // the same g:a), not just the representative highest — otherwise a CVE that
181
181
  // only affects a lower-versioned profile variant would be missed.
182
182
  const versions = (dep.versions && dep.versions.length) ? dep.versions : [dep.version];
183
+ // Only CONCRETE, resolved versions can be matched. A dep whose version is null
184
+ // or an unresolved `${property}` (typically pinned by an external BOM like
185
+ // spring-boot-dependencies) carries NO version we can compare — matching it
186
+ // against every "affected" range would flag it as vulnerable to ALL the
187
+ // coordinate's CVEs (a flood of false CRITICALs with version "?"). Skip it;
188
+ // it's already reported in chapter 0 as an "unresolved-versions" warning.
189
+ const concrete = versions.filter(v => v && !/\$\{/.test(String(v)));
190
+ if (!concrete.length) return matches;
183
191
  for (const e of entries) {
184
- for (const ver of versions) {
185
- const affected = (e.ranges || []).some(r => {
186
- if (!ver) return r.status === "affected"; // unknown version → assume affected
187
- return isVersionAffected(ver, r);
188
- });
192
+ for (const ver of concrete) {
193
+ const affected = (e.ranges || []).some(r => isVersionAffected(ver, r));
189
194
  if (affected) {
190
195
  const vdep = ver === dep.version ? dep : { ...dep, version: ver };
191
196
  matches.push({ dep: vdep, cve: { ...e }, confidence });
@@ -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/transitive.js CHANGED
@@ -183,12 +183,18 @@ function resolveProps(value, props, builtins, depth = 0) {
183
183
  async function effectivePom(g, a, v, opts = {}, seen = new Set()) {
184
184
  const key = `${g}:${a}:${v}`;
185
185
  if (seen.has(key)) return null;
186
+ // Opt-in cross-call memo (used by the per-module overlay, which resolves the
187
+ // same shared parents/BOMs once per module). Immutable POMs → the effective
188
+ // result for a g:a:v is stable, so caching the finished object is safe; callers
189
+ // only READ eff.depMgmt/eff.deps. Only active when opts.effCache is supplied,
190
+ // so existing single-shot callers are byte-for-byte unchanged.
191
+ if (opts.effCache && opts.effCache.has(key)) return opts.effCache.get(key);
186
192
  seen.add(key);
187
193
 
188
194
  const xml = await fetchPom(g, a, v, opts);
189
- if (!xml) return null;
195
+ if (!xml) { if (opts.effCache) opts.effCache.set(key, null); return null; }
190
196
  const pom = await parsePomXml(xml);
191
- if (!pom) return null;
197
+ if (!pom) { if (opts.effCache) opts.effCache.set(key, null); return null; }
192
198
 
193
199
  let merged = {
194
200
  groupId: pom.groupId,
@@ -243,6 +249,7 @@ async function effectivePom(g, a, v, opts = {}, seen = new Set()) {
243
249
  }
244
250
  merged.depMgmt = expanded;
245
251
 
252
+ if (opts.effCache) opts.effCache.set(key, merged);
246
253
  return merged;
247
254
  }
248
255
 
@@ -0,0 +1,193 @@
1
+ /**
2
+ * lib/version-overlay.js — recover transitive dependency versions that Maven's
3
+ * PER-MODULE mediation keeps but fad's GLOBAL transitive pass masks.
4
+ *
5
+ * The problem: `expandWithTransitives` (cve-match.js) resolves the whole reactor as
6
+ * ONE tree with ONE global `rootDepMgmt` (the highest version of every coord seen
7
+ * anywhere). So a `<dependencyManagement>` pin in module A is force-applied to a
8
+ * transitive of the unrelated module B — e.g. `controller` pins poi 5.4.1 and the
9
+ * stress-tests island's `jmeter → poi 3.11` gets rewritten to 5.4.1, hiding
10
+ * CVE-2017-12626. Maven applies depMgmt only inside the subtree that declares it.
11
+ *
12
+ * The fix (additive overlay): keep the global pass UNCHANGED as the base (no
13
+ * regression), then re-resolve EACH module independently with ONLY that module's
14
+ * own effective depMgmt (its local parent chain + its external parent/import-BOMs),
15
+ * and APPEND any (g:a, version) it finds that isn't already in the coord's
16
+ * `versions[]`. Never removes, never reseeds — so it can only ADD coverage, and the
17
+ * per-module version it finds is the one genuinely on that module's classpath
18
+ * (so it's a true positive, not a force-elevated one).
19
+ *
20
+ * Offline-aware (cache-first via transitive.js#fetchPom) and memoised across modules
21
+ * with a shared `effCache` so 25 modules stay fast.
22
+ */
23
+ const core = require("./core");
24
+ const { resolveTransitiveDeps, effectivePom } = require("./transitive");
25
+ const { resolveDepVersion } = require("./cve-match");
26
+
27
+ const coord = core.coord;
28
+ const isConcrete = v => v != null && !/\$\{/.test(String(v));
29
+
30
+ /** xml2js dependency node → flat descriptor (groupId/artifactId/version/scope/…). */
31
+ function flattenDep(d) {
32
+ return {
33
+ groupId: coord(d.groupId?.[0]),
34
+ artifactId: coord(d.artifactId?.[0]),
35
+ rawVersion: coord(d.version?.[0]),
36
+ scope: coord(d.scope?.[0]) || "compile",
37
+ optional: d.optional?.[0] === "true",
38
+ isImport: d.scope?.[0] === "import",
39
+ exclusions: (d.exclusions?.[0]?.exclusion || []).map(e => ({
40
+ groupId: coord(e.groupId?.[0]),
41
+ artifactId: coord(e.artifactId?.[0]),
42
+ })),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Walk a module's LOCAL parent chain (child first). Stops at the first external
48
+ * parent (one not present in the source tree) and reports it separately, so the
49
+ * caller can resolve its managed table from Maven Central.
50
+ * @returns { chain: [pomPath, parentPath, …], externalParent: {groupId,artifactId,version}|null }
51
+ */
52
+ function localChain(pomPath, store) {
53
+ const chain = [];
54
+ const seen = new Set();
55
+ let cur = pomPath;
56
+ let externalParent = null;
57
+ while (cur && !seen.has(cur)) {
58
+ seen.add(cur);
59
+ chain.push(cur);
60
+ const meta = store.byPath[cur];
61
+ if (!meta) break;
62
+ const parentPath = core.resolveParentPath(cur, meta.parentInfo, store);
63
+ if (!parentPath) {
64
+ const p = meta.parentInfo;
65
+ if (p?.groupId && p?.artifactId && p?.version) externalParent = { groupId: p.groupId, artifactId: p.artifactId, version: p.version };
66
+ break;
67
+ }
68
+ cur = parentPath;
69
+ }
70
+ return { chain, externalParent };
71
+ }
72
+
73
+ /**
74
+ * Build the EFFECTIVE managed-version map for ONE module — exactly the depMgmt that
75
+ * Maven would apply when resolving THIS module's dependencies, and no other module's.
76
+ * Closest-declared pin wins (set-if-absent while climbing child → parent).
77
+ *
78
+ * Sources, in precedence order:
79
+ * 1. each local pom in the parent chain's own <dependencyManagement> (local
80
+ * scope=import BOMs are already expanded inline by core.getAllInheritedProps);
81
+ * 2. the chain's external <parent> (e.g. spring-boot-starter-parent) managed table;
82
+ * 3. external scope=import BOMs (e.g. spring-boot-dependencies) declared in the chain.
83
+ * @returns Map<"g:a", version>
84
+ */
85
+ async function buildModuleManagement(pomPath, store, propsByPom, opts = {}) {
86
+ const map = new Map();
87
+ const setIf = (k, v) => { if (k && isConcrete(v) && !map.has(k)) map.set(k, String(v)); };
88
+ const { chain, externalParent } = localChain(pomPath, store);
89
+ const externalBoms = [];
90
+
91
+ for (const pom of chain) {
92
+ const entry = propsByPom[pom];
93
+ if (!entry) continue;
94
+ const props = entry.properties || {};
95
+ for (const node of entry.dependencyManagement || []) {
96
+ const g = coord(node.groupId?.[0]);
97
+ const a = coord(node.artifactId?.[0]);
98
+ if (!g || !a) continue;
99
+ const v = resolveDepVersion(coord(node.version?.[0]), props);
100
+ if (node.scope?.[0] === "import") {
101
+ // Local import BOMs are already expanded inline by getAllInheritedProps;
102
+ // only chase EXTERNAL ones (not present in the source tree).
103
+ const local = (v && store.byId[`${g}:${a}:${v}`]) || store.byId[`${g}:${a}`];
104
+ if (!local && isConcrete(v)) externalBoms.push({ groupId: g, artifactId: a, version: v });
105
+ continue;
106
+ }
107
+ setIf(`${g}:${a}`, v);
108
+ }
109
+ }
110
+
111
+ // External parent + external import BOMs: pull their managed tables from Maven
112
+ // Central (cache-first, memoised). Closest local pins already set win.
113
+ const externals = externalParent ? [externalParent, ...externalBoms] : externalBoms;
114
+ for (const ext of externals) {
115
+ let eff = null;
116
+ try { eff = await effectivePom(ext.groupId, ext.artifactId, ext.version, opts); } catch { eff = null; }
117
+ if (eff?.depMgmt) for (const d of eff.depMgmt) setIf(`${d.groupId}:${d.artifactId}`, d.version);
118
+ }
119
+ return map;
120
+ }
121
+
122
+ /**
123
+ * The direct dependencies declared by ONE module, with concrete versions resolved
124
+ * (own ${properties} → module's managed map → the global resolved version). These
125
+ * seed the per-module transitive walk.
126
+ */
127
+ function buildModuleDirects(pomPath, propsByPom, moduleMgmt, resolvedDeps, opts = {}) {
128
+ const entry = propsByPom[pomPath];
129
+ if (!entry) return [];
130
+ const props = entry.properties || {};
131
+ const out = [];
132
+ for (const node of entry.dependencies || []) {
133
+ const d = flattenSafe(node);
134
+ if (!d || !d.groupId || !d.artifactId || d.optional || d.isImport) continue;
135
+ if (d.scope === "test" && !opts.includeTestDeps) continue;
136
+ if (d.scope === "system" || d.scope === "import") continue;
137
+ let v = resolveDepVersion(d.rawVersion, props);
138
+ if (!isConcrete(v)) v = moduleMgmt.get(`${d.groupId}:${d.artifactId}`) || resolvedDeps.get(`${d.groupId}:${d.artifactId}`)?.version || null;
139
+ if (!isConcrete(v)) continue;
140
+ out.push({ groupId: d.groupId, artifactId: d.artifactId, version: String(v), scope: d.scope, exclusions: d.exclusions });
141
+ }
142
+ return out;
143
+ }
144
+ // flattenSafe guards against malformed nodes (xml2js can yield odd shapes).
145
+ function flattenSafe(node) { try { return flattenDep(node); } catch { return null; } }
146
+
147
+ /**
148
+ * Additive per-module overlay. Mutates `resolvedDeps`: for every coord already in
149
+ * the scan set, appends any concrete version found by a faithful per-module
150
+ * resolution that the global pass masked. Returns a small diagnostics object incl.
151
+ * the (g:a, version) pairs recovered (used for the false-positive measurement).
152
+ */
153
+ async function expandPerModuleOverlay(resolvedDeps, store, propsByPom, opts = {}) {
154
+ if (!store || !propsByPom) return { appended: 0, modules: 0, recovered: [] };
155
+ const effCache = opts.effCache || new Map();
156
+ const tOpts = { ...opts, effCache };
157
+ const recovered = [];
158
+ let modules = 0;
159
+
160
+ for (const pomPath of Object.keys(propsByPom)) {
161
+ const moduleMgmt = await buildModuleManagement(pomPath, store, propsByPom, tOpts);
162
+ const directs = buildModuleDirects(pomPath, propsByPom, moduleMgmt, resolvedDeps, tOpts);
163
+ if (!directs.length) continue;
164
+ modules++;
165
+
166
+ let trans;
167
+ try {
168
+ trans = await resolveTransitiveDeps(directs, {
169
+ ...tOpts,
170
+ rootDepMgmt: moduleMgmt,
171
+ maxDepth: opts.maxDepth || 6,
172
+ includedScopes: ["compile", "runtime", "provided"],
173
+ });
174
+ } catch { continue; }
175
+
176
+ for (const [key, t] of trans) {
177
+ const v = t.version;
178
+ if (!isConcrete(v)) continue;
179
+ const existing = resolvedDeps.get(key);
180
+ if (!existing) continue; // additive to coords already scanned
181
+ if (existing.provenance === "embedded" || existing.provenance === "binary") continue;
182
+ if (!Array.isArray(existing.versions)) existing.versions = isConcrete(existing.version) ? [existing.version] : [];
183
+ if (existing.versions.includes(String(v))) continue; // already scanned this version
184
+ existing.versions.push(String(v));
185
+ existing.maskedVersions = existing.maskedVersions || [];
186
+ existing.maskedVersions.push({ version: String(v), via: t.via, viaPaths: t.viaPaths, module: pomPath, depth: t.depth });
187
+ recovered.push({ coord: key, version: String(v), module: pomPath, had: existing.version });
188
+ }
189
+ }
190
+ return { appended: recovered.length, modules, recovered };
191
+ }
192
+
193
+ module.exports = { expandPerModuleOverlay, buildModuleManagement, buildModuleDirects, localChain };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "Scan ALL Maven, npm, Yarn, Composer, Python, C#/.NET, Go & Ruby dependencies — plus embedded JARs (fat-jars/war/ear) — in a source tree ONE SHOT without mvn/python/etc — CVE (EPSS/KEV-prioritised), EOL, obsolete, outdated & licenses, with SBOM/CSAF/SARIF/JSON exports, CI gating and fix recos",
5
5
  "keywords": [
6
6
  "sca",