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/retire.js CHANGED
@@ -36,15 +36,38 @@ const RETIRE_SIG_DIR = path.join(os.homedir(), ".fad-checker", "retire-signature
36
36
  const RETIRE_SIG_FILE = path.join(RETIRE_SIG_DIR, "jsrepository-v5.json");
37
37
  const RETIRE_REPO_URL = "https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository-v5.json";
38
38
 
39
+ // retire always emits ABSOLUTE file paths (it resolves --jspath). Make them
40
+ // relative to the scan root robustly — resolving BOTH sides so it works whether
41
+ // the caller passed -s as a relative ("./proj") or absolute path. The old
42
+ // `file.startsWith(srcDir)` guard silently left paths absolute for a relative -s.
43
+ function relToSrc(srcDir, file) {
44
+ if (!file) return file;
45
+ if (!srcDir) return file;
46
+ try {
47
+ const rel = path.relative(path.resolve(srcDir), path.resolve(file));
48
+ return rel && !rel.startsWith("..") ? rel : file;
49
+ } catch { return file; }
50
+ }
51
+
39
52
  function cacheKey(srcDir) {
40
53
  return crypto.createHash("md5").update(path.resolve(srcDir)).digest("hex") + ".json";
41
54
  }
42
55
 
56
+ // Cache schema version. Bumped to 2 when retire started running with `--verbose`
57
+ // (so the cached body carries the FULL vendored-JS inventory, not just vulnerable
58
+ // hits). A cached entry without `_schema >= 2` was written by a pre-verbose build
59
+ // (e.g. 1.0.6) and its body holds vuln-only data — trusting it would silently empty
60
+ // the inventory chapter (1D) on an offline re-run. We treat such an entry as a
61
+ // cache MISS so the normal path re-scans (online, or offline with local signatures)
62
+ // and the offline report reproduces the online one.
63
+ const RETIRE_CACHE_SCHEMA = 2;
64
+
43
65
  function readCache(srcDir) {
44
66
  const p = path.join(RETIRE_CACHE_DIR, cacheKey(srcDir));
45
67
  if (!fs.existsSync(p)) return null;
46
68
  try {
47
69
  const data = JSON.parse(fs.readFileSync(p, "utf8"));
70
+ if (!(data._schema >= RETIRE_CACHE_SCHEMA)) return null; // legacy / pre-verbose → re-scan
48
71
  if (Date.now() - data._fetchedAt < RETIRE_CACHE_TTL_MS) return data.body;
49
72
  } catch { /* ignore */ }
50
73
  return null;
@@ -53,7 +76,7 @@ function readCache(srcDir) {
53
76
  function writeCache(srcDir, body) {
54
77
  fs.mkdirSync(RETIRE_CACHE_DIR, { recursive: true });
55
78
  fs.writeFileSync(path.join(RETIRE_CACHE_DIR, cacheKey(srcDir)),
56
- JSON.stringify({ _fetchedAt: Date.now(), body }));
79
+ JSON.stringify({ _schema: RETIRE_CACHE_SCHEMA, _fetchedAt: Date.now(), body }));
57
80
  }
58
81
 
59
82
  function findRetireBin() {
@@ -62,6 +85,28 @@ function findRetireBin() {
62
85
  return "retire"; // fall back to PATH
63
86
  }
64
87
 
88
+ // Decide HOW to launch retire. The compiled (bun) binary has no node_modules to
89
+ // spawn the retire CLI from and the air-gapped box has no `retire` on PATH — so it
90
+ // re-execs ITSELF with __FAD_RETIRE__ set, and the entry point (fad-checker.js)
91
+ // hands off to the statically-bundled retire CLI. Pure for testability.
92
+ // - localBin present (node dev / node_modules) → run it directly.
93
+ // - else running under bun (compiled binary) → self-invoke this executable.
94
+ // - else → `retire` on PATH (last resort).
95
+ function chooseRetireLauncher({ localBin, isBun, execPath }) {
96
+ if (localBin) return { cmd: localBin, env: null };
97
+ if (isBun) return { cmd: execPath, env: { __FAD_RETIRE__: "1" } };
98
+ return { cmd: "retire", env: null };
99
+ }
100
+
101
+ function findRetireLauncher() {
102
+ const local = path.join(__dirname, "..", "node_modules", ".bin", "retire");
103
+ return chooseRetireLauncher({
104
+ localBin: fs.existsSync(local) ? local : null,
105
+ isBun: !!(process.versions && process.versions.bun),
106
+ execPath: process.execPath,
107
+ });
108
+ }
109
+
65
110
  /**
66
111
  * Fetch retire's signature DB to a stable file inside ~/.fad-checker/ so it can
67
112
  * be bundled by --export-cache and reused offline via --jsrepo. Network call —
@@ -117,6 +162,19 @@ function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
117
162
 
118
163
  const SEV_RANK = { critical: 5, high: 4, medium: 3, low: 2, none: 1 };
119
164
 
165
+ // Pick the human-meaningful line out of retire's stderr for a failure message.
166
+ // retire dumps a multi-line stack trace; the useful part is the first non-empty,
167
+ // non-stack-frame line (preferring an ENOENT / "no such file" / permission line).
168
+ function retireFailureReason(stderr, fallback) {
169
+ const lines = String(stderr || "")
170
+ .split("\n")
171
+ .map(l => l.trim())
172
+ .filter(l => l && !/^at\s/.test(l));
173
+ if (!lines.length) return fallback;
174
+ const hot = lines.find(l => /ENOENT|no such file|permission denied|EACCES/i.test(l));
175
+ return hot || lines[0];
176
+ }
177
+
120
178
  /**
121
179
  * Extract the full inventory of identified vendored JS libraries (vulnerable or
122
180
  * not) from retire's --verbose output. Each entry: the standalone library, where
@@ -131,7 +189,7 @@ function extractVendoredInventory(raw, srcDir) {
131
189
  const files = Array.isArray(raw) ? raw : (raw.data || []);
132
190
  for (const f of files) {
133
191
  const file = f.file;
134
- const relFile = srcDir && file?.startsWith(srcDir) ? path.relative(srcDir, file) : file;
192
+ const relFile = relToSrc(srcDir, file);
135
193
  for (const res of f.results || []) {
136
194
  if (!res.component) continue;
137
195
  const vulns = res.vulnerabilities || [];
@@ -169,6 +227,10 @@ function extractVendoredInventory(raw, srcDir) {
169
227
  */
170
228
  async function runRetire(srcDir, opts = {}) {
171
229
  const { verbose, force, offline } = opts;
230
+ // Optional diagnostics collector: callers (scanWithRetireFull) read diag.error to
231
+ // surface a genuine SCAN FAILURE (retire crashed / produced no parseable output)
232
+ // as a report warning — instead of letting it masquerade as "no vendored JS found".
233
+ const diag = opts.diag || {};
172
234
  // No source tree (e.g. --import-anonymized) → nothing to scan for vendored JS.
173
235
  if (!srcDir) { if (verbose) console.warn("retire: no source dir — skipped"); return null; }
174
236
  // Findings-cache fast path (path-keyed). Works online and offline.
@@ -189,7 +251,7 @@ async function runRetire(srcDir, opts = {}) {
189
251
  return null;
190
252
  }
191
253
 
192
- const bin = findRetireBin();
254
+ const launcher = findRetireLauncher();
193
255
  const ignoredDirs = [
194
256
  "node_modules", "bower_components", "jspm_packages",
195
257
  ".git", ".idea", ".vscode", ".gradle", ".mvn",
@@ -202,15 +264,27 @@ async function runRetire(srcDir, opts = {}) {
202
264
  const args = buildRetireArgs({ srcDir, outPath: tmpOut, ignoredDirs, jsRepo: haveSig ? RETIRE_SIG_FILE : null });
203
265
  if (verbose) console.log(`retire: scanning ${srcDir}…${haveSig ? " (local signatures)" : ""}`);
204
266
 
267
+ let execErr = null;
205
268
  try {
206
269
  // retire.js exits with code 13 when it finds vulnerabilities — that's
207
270
  // expected. Catch and ignore the non-zero exit; the JSON file is still
208
271
  // produced.
209
- await execFileP(bin, args, { maxBuffer: 1024 * 1024 * 64 });
272
+ await execFileP(launcher.cmd, args, {
273
+ maxBuffer: 1024 * 1024 * 64,
274
+ env: launcher.env ? { ...process.env, ...launcher.env } : process.env,
275
+ });
210
276
  } catch (err) {
211
- // exit code 13 (or anything where the output file exists) is OK
212
- if (!fs.existsSync(tmpOut)) {
213
- if (verbose) console.warn(`retire: failed to run ${err.message}`);
277
+ execErr = err;
278
+ // exit code 13 (or anything where a non-empty output file exists) is OK.
279
+ // A missing OR empty (0-byte) output file means retire crashed mid-walk
280
+ // (e.g. ENOENT on the source path, an unreadable file) — a real failure.
281
+ let size = -1;
282
+ try { size = fs.statSync(tmpOut).size; } catch { /* missing */ }
283
+ if (size <= 0) {
284
+ const reason = retireFailureReason(err.stderr, err.message);
285
+ diag.error = `retire.js scan failed: ${reason}`;
286
+ if (verbose) console.warn(`retire: failed to run — ${reason}`);
287
+ try { fs.unlinkSync(tmpOut); } catch { /* best effort */ }
214
288
  return null;
215
289
  }
216
290
  }
@@ -220,6 +294,8 @@ async function runRetire(srcDir, opts = {}) {
220
294
  const body = fs.readFileSync(tmpOut, "utf8");
221
295
  parsed = JSON.parse(body);
222
296
  } catch (err) {
297
+ const reason = retireFailureReason(execErr && execErr.stderr, err.message);
298
+ diag.error = `retire.js scan failed: ${reason}`;
223
299
  if (verbose) console.warn(`retire: could not parse output — ${err.message}`);
224
300
  return null;
225
301
  } finally {
@@ -262,7 +338,7 @@ function normaliseRetireResults(raw, srcDir) {
262
338
  const files = Array.isArray(raw) ? raw : (raw.data || []);
263
339
  for (const f of files) {
264
340
  const file = f.file;
265
- const relFile = srcDir && file?.startsWith(srcDir) ? path.relative(srcDir, file) : file;
341
+ const relFile = relToSrc(srcDir, file);
266
342
  for (const res of f.results || []) {
267
343
  const component = res.component;
268
344
  const version = res.version;
@@ -319,11 +395,13 @@ async function scanWithRetire(srcDir, opts = {}) {
319
395
  * JavaScript"). One retire run feeds both.
320
396
  */
321
397
  async function scanWithRetireFull(srcDir, opts = {}) {
322
- const raw = await runRetire(srcDir, opts);
323
- if (!raw) return { matches: [], inventory: [] };
398
+ const diag = {};
399
+ const raw = await runRetire(srcDir, { ...opts, diag });
400
+ if (!raw) return { matches: [], inventory: [], error: diag.error || null };
324
401
  return {
325
402
  matches: normaliseRetireResults(raw, srcDir),
326
403
  inventory: extractVendoredInventory(raw, srcDir),
404
+ error: null,
327
405
  };
328
406
  }
329
407
 
@@ -334,9 +412,16 @@ module.exports = {
334
412
  runRetire,
335
413
  normaliseRetireResults,
336
414
  findRetireBin,
415
+ findRetireLauncher,
416
+ chooseRetireLauncher,
337
417
  warmRetireSignatures,
338
418
  ensureSignatures,
339
419
  buildRetireArgs,
420
+ retireFailureReason,
421
+ readCache,
422
+ writeCache,
423
+ cacheKey,
424
+ RETIRE_CACHE_SCHEMA,
340
425
  RETIRE_CACHE_DIR,
341
426
  RETIRE_SIG_DIR,
342
427
  RETIRE_SIG_FILE,
@@ -53,15 +53,24 @@ function detectScanCompletenessWarnings(resolvedDeps, opts = {}) {
53
53
  }
54
54
 
55
55
  if (unresolved.length) {
56
- // Dedupe by g:a (same coord may appear in several modules)
57
- const seenKeys = new Set();
58
- const items = [];
56
+ // Dedupe by g:a (same coord may appear in several modules), MERGING the
57
+ // manifest paths so the report can show WHERE each unresolved dep is declared.
58
+ // Items are objects ({ id, manifestPaths }) — renderWarningItems() renders the
59
+ // "defined in: <pom.xml>" line for that shape (same as the private-libs warning).
60
+ const byKey = new Map();
59
61
  for (const d of unresolved) {
60
62
  const k = `${d.groupId}:${d.artifactId}`;
61
- if (seenKeys.has(k)) continue;
62
- seenKeys.add(k);
63
- items.push(`${k}${d.version ? " (" + d.version + ")" : " (no version resolved)"}`);
63
+ const paths = (d.manifestPaths && d.manifestPaths.length) ? d.manifestPaths
64
+ : (d.pomPaths && d.pomPaths.length) ? d.pomPaths
65
+ : [];
66
+ let entry = byKey.get(k);
67
+ if (!entry) {
68
+ entry = { id: `${k}${d.version ? " (" + d.version + ")" : " (no version resolved)"}`, manifestPaths: [] };
69
+ byKey.set(k, entry);
70
+ }
71
+ for (const p of paths) if (!entry.manifestPaths.includes(p)) entry.manifestPaths.push(p);
64
72
  }
73
+ const items = [...byKey.values()];
65
74
  warnings.push({
66
75
  type: "unresolved-versions",
67
76
  count: items.length,
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
 
package/lib/ui.js CHANGED
@@ -17,11 +17,12 @@ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇",
17
17
  const TITLE_A = "fad-checker";
18
18
  const TITLE_B = "Autonomous Dependency Checker";
19
19
 
20
- function banner() {
21
- const raw = `${TITLE_A} · ${TITLE_B}`;
20
+ function banner(version) {
21
+ const ver = version ? `v${version}` : "";
22
+ const raw = `${TITLE_A} ${ver} · ${TITLE_B}`.replace(" ", " ");
22
23
  const bar = "─".repeat(raw.length + 2);
23
24
  console.log(chalk.cyan(`\n╭${bar}╮`));
24
- console.log(chalk.cyan("│ ") + chalk.bold.white(TITLE_A) + chalk.cyan(" · ") + chalk.whiteBright(TITLE_B) + chalk.cyan(" │"));
25
+ console.log(chalk.cyan("│ ") + chalk.bold.white(TITLE_A) + (ver ? " " + chalk.dim(ver) : "") + chalk.cyan(" · ") + chalk.whiteBright(TITLE_B) + chalk.cyan(" │"));
25
26
  console.log(chalk.cyan(`╰${bar}╯`));
26
27
  }
27
28
 
@@ -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.0",
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",