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/README.md +32 -117
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +1 -0
- package/fad-checker.js +122 -16
- package/lib/codecs/npm.codec.js +2 -2
- package/lib/cve-match.js +10 -5
- package/lib/cve-report.js +65 -20
- package/lib/embedded.js +57 -0
- package/lib/json-export.js +7 -1
- package/lib/manifest-copy.js +75 -0
- package/lib/maven-bom.js +104 -0
- package/lib/outdated.js +19 -7
- package/lib/retire.js +95 -10
- package/lib/scan-completeness.js +15 -6
- package/lib/transitive.js +9 -2
- package/lib/ui.js +4 -3
- package/lib/version-overlay.js +193 -0
- package/package.json +1 -1
- package/fad-checker-report/cve-report.doc +0 -1168
- package/fad-checker-report/cve-report.html +0 -1293
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 =
|
|
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
|
|
323
|
-
|
|
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,
|
package/lib/scan-completeness.js
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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.
|
|
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",
|