fad-checker 2.1.0 → 2.1.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 +8 -13
- package/fad-checker-report/cve-report.doc +643 -105
- package/fad-checker-report/cve-report.html +653 -108
- package/fad-checker.js +103 -12
- package/lib/codecs/index.js +66 -2
- package/lib/codecs/maven/jar-scan.js +174 -28
- package/lib/codecs/maven.codec.js +2 -2
- package/lib/codecs/npm/collect.js +3 -1
- package/lib/codecs/npm/parse.js +25 -0
- package/lib/codecs/npm.codec.js +5 -1
- package/lib/core.js +15 -0
- package/lib/cve-report.js +3 -1
- package/lib/license-policy.js +3 -1
- package/lib/outdated.js +3 -0
- package/lib/parallel-walk.js +47 -0
- package/package.json +1 -1
package/fad-checker.js
CHANGED
|
@@ -20,7 +20,10 @@ const ui = require("./lib/ui");
|
|
|
20
20
|
|
|
21
21
|
const core = require("./lib/core");
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// require() (not fs.readFileSync) so bun --compile statically bundles package.json
|
|
24
|
+
// into the binary — otherwise the compiled exe tries to read it off disk at runtime
|
|
25
|
+
// (from $bunfs/root) and crashes with ENOENT. Keeps the bun builds fully standalone.
|
|
26
|
+
const pkg = require("./package.json");
|
|
24
27
|
|
|
25
28
|
// -------- bash/zsh completion shortcut (must run before required-options parse) --------
|
|
26
29
|
if (process.argv.includes("--completion")) {
|
|
@@ -269,20 +272,87 @@ if (options.src && options.target) {
|
|
|
269
272
|
}
|
|
270
273
|
}
|
|
271
274
|
|
|
272
|
-
|
|
275
|
+
// Maven Central presence cache (~/.fad-checker/maven-exists-cache.json) — keyed by
|
|
276
|
+
// "g:a", value true (on a repo) / false (absent → likely private). Persisted so an
|
|
277
|
+
// online warm-up populates it, --export-cache ships it, and an --offline air-gapped
|
|
278
|
+
// run reads it instead of probing the network. Returns:
|
|
279
|
+
// true → present on a configured repo
|
|
280
|
+
// false → absent (likely private)
|
|
281
|
+
// null → unknown (offline + not cached, or probe error) — caller must NOT guess
|
|
282
|
+
const MAVEN_EXISTS_CACHE_PATH = require("path").join(require("./lib/outdated").CACHE_DIR, "maven-exists-cache.json");
|
|
283
|
+
const MAVEN_EXISTS_MAX_AGE_MS = 7 * 24 * 3600 * 1000; // 7 days
|
|
284
|
+
|
|
285
|
+
async function checkMavenLibExist(groupId, artifactId, repos, cache, opts = {}) {
|
|
273
286
|
const g = core.coord(groupId);
|
|
274
287
|
const a = core.coord(artifactId);
|
|
275
|
-
if (!g || !a) return
|
|
288
|
+
if (!g || !a) return null;
|
|
289
|
+
const key = `${g}:${a}`;
|
|
290
|
+
if (cache && Object.prototype.hasOwnProperty.call(cache.entries, key)) return cache.entries[key];
|
|
291
|
+
if (opts.offline) return null; // air-gapped + not warmed: honestly unknown, never network
|
|
276
292
|
const p = `${g.replace(/\./g, "/")}/${a}/maven-metadata.xml`;
|
|
277
293
|
const { existsInAny } = require("./lib/maven-repo");
|
|
278
294
|
try {
|
|
279
295
|
const hit = await existsInAny(repos, p, { userAgent: "fad-checker-existence" });
|
|
280
|
-
if (
|
|
281
|
-
if (verbose) console.log(chalk.dim(` not on any repo: ${g}:${a}`));
|
|
282
|
-
return
|
|
296
|
+
if (cache) cache.entries[key] = !!hit;
|
|
297
|
+
if (!hit && verbose) console.log(chalk.dim(` not on any repo: ${g}:${a}`));
|
|
298
|
+
return !!hit;
|
|
283
299
|
} catch (err) {
|
|
284
300
|
if (verbose) console.info(chalk.dim(` error querying repos: ${g}:${a} — ${err.message}`));
|
|
285
|
-
return
|
|
301
|
+
return null; // probe failed → unknown, don't poison the cache or mislabel as private
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Build an onProgress callback for the embedded-JAR scan so the user sees what's
|
|
307
|
+
* happening — the scan reads + unzips archives synchronously and can block for a
|
|
308
|
+
* while on big or numerous fat-jars (incl. the silent recursion through a fat-jar's
|
|
309
|
+
* bundled libs). The scanner reports EVERY archive (top-level + nested):
|
|
310
|
+
* - On a TTY: one transient line, rewritten in place, naming the archive being
|
|
311
|
+
* read right now (so a long pause clearly points at the culprit). Cleared at end.
|
|
312
|
+
* - Off a TTY (CI/pipe): a throttled line every ~250 archives so logs show forward
|
|
313
|
+
* motion without being spammed, plus a start and a final summary line.
|
|
314
|
+
* Returns a fresh closure per codec.
|
|
315
|
+
*/
|
|
316
|
+
function makeJarProgress() {
|
|
317
|
+
let total = 0, lastLogged = 0;
|
|
318
|
+
const STEP = 250;
|
|
319
|
+
return (ev) => {
|
|
320
|
+
if (!ev) return;
|
|
321
|
+
if (ev.phase === "start") {
|
|
322
|
+
total = ev.total || 0;
|
|
323
|
+
if (total && !ui.isTTY) ui.info(chalk.dim(`scanning ${total} embedded archive(s) (.jar/.war/.ear)…`));
|
|
324
|
+
} else if (ev.phase === "scan" && total) {
|
|
325
|
+
if (ui.isTTY) {
|
|
326
|
+
const head = total ? `${ev.scanned}/${total}+` : String(ev.scanned);
|
|
327
|
+
process.stdout.write(`\r ${chalk.dim("·")} ${chalk.dim(`reading embedded JARs (${head})`)} ${chalk.dim(ev.path)}\x1b[K`);
|
|
328
|
+
} else if (ev.scanned - lastLogged >= STEP) {
|
|
329
|
+
lastLogged = ev.scanned;
|
|
330
|
+
ui.info(chalk.dim(`… ${ev.scanned} archive(s) read (current: ${ev.path})`));
|
|
331
|
+
}
|
|
332
|
+
} else if (ev.phase === "done") {
|
|
333
|
+
if (total && ui.isTTY) process.stdout.write("\r\x1b[K");
|
|
334
|
+
else if (total && !ui.isTTY) ui.info(chalk.dim(`scanned ${ev.scanned} archive(s) → ${ev.found} embedded coord(s)`));
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Run `fn` (sync or async) while telling the user which phase is in flight, so a
|
|
341
|
+
* long pause is attributable instead of a silent hang. TTY: a transient line that's
|
|
342
|
+
* cleared when done. Non-TTY: a plain "· <label> …" line. Either way, a phase that
|
|
343
|
+
* takes >3s prints "· <label> took Ns" so slow steps self-report even without -v.
|
|
344
|
+
*/
|
|
345
|
+
async function timedPhase(label, fn) {
|
|
346
|
+
if (ui.isTTY) process.stdout.write(` ${chalk.dim("·")} ${chalk.dim(label + " …")}\x1b[K`);
|
|
347
|
+
else ui.info(chalk.dim(label + " …"));
|
|
348
|
+
const t0 = Date.now();
|
|
349
|
+
try {
|
|
350
|
+
return await fn();
|
|
351
|
+
} finally {
|
|
352
|
+
const ms = Date.now() - t0;
|
|
353
|
+
if (ui.isTTY) process.stdout.write("\r\x1b[K");
|
|
354
|
+
if (ms > 3000) ui.info(chalk.dim(`${label} took ${(ms / 1000).toFixed(1)}s`));
|
|
355
|
+
else if (verbose) ui.info(chalk.dim(`${label} done in ${ms}ms`));
|
|
286
356
|
}
|
|
287
357
|
}
|
|
288
358
|
|
|
@@ -332,13 +402,18 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
|
|
|
332
402
|
const { detectCodecs, allCodecs, getCodec } = require("./lib/codecs");
|
|
333
403
|
const { resolveActiveCodecs } = require("./lib/codecs/select");
|
|
334
404
|
const eco = (options.ecosystem || "auto").toLowerCase();
|
|
335
|
-
const detected = (eco === "auto")
|
|
405
|
+
const detected = (eco === "auto")
|
|
406
|
+
? (await timedPhase("detecting ecosystems", () => detectCodecs(options.src))).map(c => c.id)
|
|
407
|
+
: allCodecs().map(c => c.id);
|
|
336
408
|
const noCodecs = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby"].filter(id => options[id] === false);
|
|
337
409
|
const activeIds = resolveActiveCodecs(eco, detected, { noCodecs, noJs: !options.js });
|
|
338
410
|
const runMaven = activeIds.includes("maven");
|
|
339
411
|
const runNpm = activeIds.includes("npm") || activeIds.includes("yarn");
|
|
340
412
|
|
|
341
413
|
// --- Collect deps from every active codec into one Map (coordKeys never collide) ---
|
|
414
|
+
// Section header first so the embedded-JAR scan can print live progress under it
|
|
415
|
+
// (the scan reads + unzips archives synchronously and would otherwise block silently).
|
|
416
|
+
ui.section("Collection");
|
|
342
417
|
const resolved = new Map();
|
|
343
418
|
let mavenCtx = null;
|
|
344
419
|
const collectWarnings = [];
|
|
@@ -347,7 +422,7 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
|
|
|
347
422
|
const codec = getCodec(id);
|
|
348
423
|
let res;
|
|
349
424
|
try {
|
|
350
|
-
res = await codec.collect(options.src, { ignoreTest: !!options.ignoreTest, deps2Exclude, verbose, scanJars: options.jars !== false, srcRoot: options.src });
|
|
425
|
+
res = await timedPhase(`collecting ${codec.label || id}`, () => codec.collect(options.src, { ignoreTest: !!options.ignoreTest, deps2Exclude, verbose, scanJars: options.jars !== false, srcRoot: options.src, onJarProgress: makeJarProgress() }));
|
|
351
426
|
} catch (err) {
|
|
352
427
|
console.warn(chalk.red(`❌ ${id} collect failed:`), chalk.dim(err.message));
|
|
353
428
|
continue;
|
|
@@ -358,7 +433,6 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
|
|
|
358
433
|
}
|
|
359
434
|
|
|
360
435
|
// --- Collection summary ---
|
|
361
|
-
ui.section("Collection");
|
|
362
436
|
const ecoCount = {};
|
|
363
437
|
let embeddedCount = 0;
|
|
364
438
|
for (const d of resolved.values()) {
|
|
@@ -432,7 +506,18 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
|
|
|
432
506
|
ui.ok("no missing Maven parent POMs");
|
|
433
507
|
}
|
|
434
508
|
|
|
509
|
+
// Private-lib detection asks each configured repo whether a missing coord
|
|
510
|
+
// exists (→ absent = likely private). Results are cached + bundled, so an
|
|
511
|
+
// --offline run reads the online-warmed cache instead of probing the network.
|
|
512
|
+
// A coord that's neither cached nor probeable (offline + cold) stays UNKNOWN —
|
|
513
|
+
// we never fake it as "private", which is what made offline both wrong and slow.
|
|
435
514
|
if (options.allLibs) {
|
|
515
|
+
const { loadJsonCache, saveJsonCache } = require("./lib/outdated");
|
|
516
|
+
const existsCache = loadJsonCache(MAVEN_EXISTS_CACHE_PATH);
|
|
517
|
+
const fresh = existsCache.meta?.fetchedAt && (Date.now() - existsCache.meta.fetchedAt) < MAVEN_EXISTS_MAX_AGE_MS;
|
|
518
|
+
if (!fresh && !options.offline) existsCache.entries = {}; // refresh stale probes when online
|
|
519
|
+
if (!existsCache.entries) existsCache.entries = {};
|
|
520
|
+
|
|
436
521
|
const anyMissingLibs = Object.keys(allPomMetadata.anyMissingById)
|
|
437
522
|
.filter(id => {
|
|
438
523
|
const parts = id.split(":");
|
|
@@ -442,14 +527,20 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
|
|
|
442
527
|
const limit = pLimit(10);
|
|
443
528
|
const results = await Promise.all(anyMissingLibs.map(id => {
|
|
444
529
|
const [g, a] = id.split(":");
|
|
445
|
-
return limit(async () => ({ id, found: await checkMavenLibExist(g, a, mavenRepos) }));
|
|
530
|
+
return limit(async () => ({ id, found: await checkMavenLibExist(g, a, mavenRepos, existsCache, { offline: options.offline }) }));
|
|
446
531
|
}));
|
|
447
|
-
|
|
532
|
+
let unknown = 0;
|
|
533
|
+
for (const r of results) {
|
|
534
|
+
if (r.found === false) privateLibIds.push(r.id);
|
|
535
|
+
else if (r.found === null) unknown++;
|
|
536
|
+
}
|
|
537
|
+
if (!options.offline) { existsCache.meta = { fetchedAt: Date.now() }; saveJsonCache(MAVEN_EXISTS_CACHE_PATH, existsCache); }
|
|
448
538
|
if (privateLibIds.length) {
|
|
449
539
|
ui.warn(`${privateLibIds.length} lib(s) absent from Maven Central (likely private):`);
|
|
450
540
|
for (const id of privateLibIds.slice(0, 10)) ui.info(chalk.magenta(id));
|
|
451
541
|
if (privateLibIds.length > 10) ui.info(chalk.dim(`…and ${privateLibIds.length - 10} more`));
|
|
452
542
|
}
|
|
543
|
+
if (unknown) ui.info(chalk.dim(`${unknown} lib(s) not in the presence cache — run online once (or --export-cache from an online host) to classify them`));
|
|
453
544
|
}
|
|
454
545
|
|
|
455
546
|
if (deps2Exclude) {
|
package/lib/codecs/index.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* @author: N.BRAUN
|
|
9
9
|
* @email: pp9ping@gmail.com
|
|
10
10
|
*/
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
11
13
|
const { assertCodecShape } = require("./codec.interface");
|
|
12
14
|
const maven = require("./maven.codec");
|
|
13
15
|
const npm = require("./npm.codec");
|
|
@@ -33,10 +35,72 @@ function allCodecs() {
|
|
|
33
35
|
return [...REGISTRY.values()].sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id));
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
// Dirs skipped during detection. Starts from the INTERSECTION of every codec's own
|
|
39
|
+
// skip set (a dir skipped here only if ALL codecs skip it → detection never misses a
|
|
40
|
+
// manifest a codec would have found) plus well-known dependency/cache/tooling dirs
|
|
41
|
+
// that are NEVER a project root (all dot-dirs or vendored-dep caches), so detection
|
|
42
|
+
// can prune them safely. NOTE: "build" / "vendor" / "bin" / "obj" are deliberately
|
|
43
|
+
// NOT here — maven keeps build/ for BOMs and several codecs scan vendor/.
|
|
44
|
+
const DETECT_SKIP = new Set([
|
|
45
|
+
"node_modules", ".git", ".idea", ".vscode", "target", "dist", "out", // intersection
|
|
46
|
+
".svn", ".hg", ".gradle", ".mvn", ".cache", ".m2", "bower_components", // never a project root
|
|
47
|
+
"jspm_packages", "__pycache__", ".venv", ".tox", ".mypy_cache", "coverage", ".next", ".nuxt",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const DETECT_CONCURRENCY = 48;
|
|
51
|
+
|
|
36
52
|
// yarn est détecté via le même arbre JS que npm ; on ne le renvoie pas en
|
|
37
53
|
// doublon de détection (npm.collect ramasse déjà yarn.lock).
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
//
|
|
55
|
+
// ONE shared walk instead of one full-tree walk per codec: we descend the tree once
|
|
56
|
+
// and, per file, test it against each codec's manifestNames (supporting "*.ext"
|
|
57
|
+
// globs). Short-circuits as soon as every codec has matched. readdir runs with bounded
|
|
58
|
+
// concurrency — on a high-latency filesystem (WSL/9p, network mounts) the walk is
|
|
59
|
+
// latency-bound, so issuing many readdirs at once is far faster than a serial walk.
|
|
60
|
+
async function detectCodecs(dir) {
|
|
61
|
+
const codecs = allCodecs().filter(c => c.id !== "yarn");
|
|
62
|
+
const matchers = codecs.map(c => {
|
|
63
|
+
const exact = new Set();
|
|
64
|
+
const exts = [];
|
|
65
|
+
for (const n of c.manifestNames || []) {
|
|
66
|
+
if (n.startsWith("*.")) exts.push(n.slice(1)); // "*.csproj" → ".csproj"
|
|
67
|
+
else exact.add(n);
|
|
68
|
+
}
|
|
69
|
+
return { codec: c, exact, exts, found: false };
|
|
70
|
+
});
|
|
71
|
+
let remaining = matchers.length;
|
|
72
|
+
const queue = [dir];
|
|
73
|
+
let active = 0;
|
|
74
|
+
|
|
75
|
+
await new Promise(resolve => {
|
|
76
|
+
let settled = false;
|
|
77
|
+
const finish = () => { if (!settled) { settled = true; resolve(); } };
|
|
78
|
+
const pump = () => {
|
|
79
|
+
if (settled) return;
|
|
80
|
+
if (remaining === 0) return finish();
|
|
81
|
+
if (queue.length === 0 && active === 0) return finish();
|
|
82
|
+
while (active < DETECT_CONCURRENCY && queue.length && remaining > 0) {
|
|
83
|
+
const cur = queue.pop();
|
|
84
|
+
active++;
|
|
85
|
+
fs.promises.readdir(cur, { withFileTypes: true }).then(entries => {
|
|
86
|
+
for (const e of entries) {
|
|
87
|
+
if (e.isDirectory()) {
|
|
88
|
+
if (!DETECT_SKIP.has(e.name)) queue.push(path.join(cur, e.name));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!e.isFile()) continue;
|
|
92
|
+
const name = e.name;
|
|
93
|
+
for (const m of matchers) {
|
|
94
|
+
if (m.found) continue;
|
|
95
|
+
if (m.exact.has(name) || m.exts.some(ext => name.endsWith(ext))) { m.found = true; remaining--; }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}).catch(() => { /* unreadable dir → skip */ }).finally(() => { active--; pump(); });
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
pump();
|
|
102
|
+
});
|
|
103
|
+
return matchers.filter(m => m.found).map(m => m.codec);
|
|
40
104
|
}
|
|
41
105
|
|
|
42
106
|
module.exports = { getCodec, allCodecs, detectCodecs, ORDER };
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
const fs = require("fs");
|
|
26
26
|
const path = require("path");
|
|
27
|
-
const { unzipSync, strFromU8 } = require("fflate");
|
|
27
|
+
const { unzipSync, inflateSync, strFromU8 } = require("fflate");
|
|
28
28
|
const { makeDepRecord } = require("../../dep-record");
|
|
29
29
|
|
|
30
30
|
const ARCHIVE_RE = /\.(jar|war|ear)$/i;
|
|
@@ -33,6 +33,15 @@ const SKIP_DIRS = new Set(["node_modules", ".git", "target", "build", "out", "di
|
|
|
33
33
|
const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
|
|
34
34
|
const MAX_DEPTH = 8;
|
|
35
35
|
|
|
36
|
+
// The ONLY entries we ever decompress out of an archive: its own Maven coordinate
|
|
37
|
+
// (pom.properties), its manifest, and any NESTED archive (a fat-jar's bundled libs,
|
|
38
|
+
// which we recurse into). Everything else — .class files, resources — is skipped.
|
|
39
|
+
function wantedEntry(name) {
|
|
40
|
+
return /^META-INF\/maven\/[^/]+\/[^/]+\/pom\.properties$/.test(name)
|
|
41
|
+
|| name === "META-INF/MANIFEST.MF"
|
|
42
|
+
|| ARCHIVE_RE.test(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
/** Parse a META-INF/maven/.../pom.properties body → { groupId, artifactId, version } | null. */
|
|
37
46
|
function coordFromPomProperties(text) {
|
|
38
47
|
const get = re => (String(text || "").match(re) || [])[1]?.trim();
|
|
@@ -90,6 +99,72 @@ function readEntries(buf, filter) {
|
|
|
90
99
|
}
|
|
91
100
|
}
|
|
92
101
|
|
|
102
|
+
const EOCD_SIG = 0x06054b50; // End Of Central Directory
|
|
103
|
+
const CEN_SIG = 0x02014b50; // central directory file header
|
|
104
|
+
const LOC_SIG = 0x04034b50; // local file header
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read ONLY the `filter`-matched entries of an on-disk zip via positioned reads on
|
|
108
|
+
* the file descriptor — without ever loading the whole archive into RAM. We read
|
|
109
|
+
* the End-Of-Central-Directory record, then the central directory, then seek to and
|
|
110
|
+
* inflate just the wanted entries. A 200 MB library jar with a 1 KB pom.properties
|
|
111
|
+
* costs a few KB of reads instead of 200 MB; its .class files are never touched.
|
|
112
|
+
*
|
|
113
|
+
* Throws on anything this minimal parser doesn't handle (ZIP64, encryption, an
|
|
114
|
+
* unknown compression method, a malformed record) so the caller can fall back to
|
|
115
|
+
* the battle-tested fflate whole-buffer path. Sizes are taken from the central
|
|
116
|
+
* directory (authoritative even when a streaming writer zeroes the local header).
|
|
117
|
+
*
|
|
118
|
+
* @returns {Object<string, Uint8Array>} matched entries, same shape as readEntries()
|
|
119
|
+
*/
|
|
120
|
+
function readZipEntriesFromFd(fd, fileSize, filter) {
|
|
121
|
+
const back = Math.min(fileSize, 65557); // max EOCD comment (65535) + 22-byte record
|
|
122
|
+
const tail = Buffer.allocUnsafe(back);
|
|
123
|
+
fs.readSync(fd, tail, 0, back, fileSize - back);
|
|
124
|
+
let e = -1;
|
|
125
|
+
for (let i = tail.length - 22; i >= 0; i--) {
|
|
126
|
+
if (tail.readUInt32LE(i) === EOCD_SIG) { e = i; break; }
|
|
127
|
+
}
|
|
128
|
+
if (e < 0) throw new Error("no EOCD record");
|
|
129
|
+
const count = tail.readUInt16LE(e + 10);
|
|
130
|
+
const cdSize = tail.readUInt32LE(e + 12);
|
|
131
|
+
const cdOff = tail.readUInt32LE(e + 16);
|
|
132
|
+
if (count === 0xffff || cdSize === 0xffffffff || cdOff === 0xffffffff) throw new Error("ZIP64");
|
|
133
|
+
|
|
134
|
+
const cd = Buffer.allocUnsafe(cdSize);
|
|
135
|
+
fs.readSync(fd, cd, 0, cdSize, cdOff);
|
|
136
|
+
const out = {};
|
|
137
|
+
let p = 0;
|
|
138
|
+
for (let i = 0; i < count; i++) {
|
|
139
|
+
if (p + 46 > cd.length || cd.readUInt32LE(p) !== CEN_SIG) throw new Error("bad central record");
|
|
140
|
+
const flags = cd.readUInt16LE(p + 8);
|
|
141
|
+
const method = cd.readUInt16LE(p + 10);
|
|
142
|
+
const compSize = cd.readUInt32LE(p + 20);
|
|
143
|
+
const uncompSize = cd.readUInt32LE(p + 24);
|
|
144
|
+
const nameLen = cd.readUInt16LE(p + 28);
|
|
145
|
+
const extraLen = cd.readUInt16LE(p + 30);
|
|
146
|
+
const commentLen = cd.readUInt16LE(p + 32);
|
|
147
|
+
const localOff = cd.readUInt32LE(p + 42);
|
|
148
|
+
const name = cd.toString("utf8", p + 46, p + 46 + nameLen);
|
|
149
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
150
|
+
if (!filter(name)) continue;
|
|
151
|
+
if (flags & 0x1) throw new Error("encrypted entry");
|
|
152
|
+
if (compSize === 0xffffffff || uncompSize === 0xffffffff || localOff === 0xffffffff) throw new Error("ZIP64 entry");
|
|
153
|
+
// The local header's name/extra lengths can differ from the central record's,
|
|
154
|
+
// so read it to locate the actual start of the entry's data.
|
|
155
|
+
const loc = Buffer.allocUnsafe(30);
|
|
156
|
+
fs.readSync(fd, loc, 0, 30, localOff);
|
|
157
|
+
if (loc.readUInt32LE(0) !== LOC_SIG) throw new Error("bad local header");
|
|
158
|
+
const dataOff = localOff + 30 + loc.readUInt16LE(26) + loc.readUInt16LE(28);
|
|
159
|
+
const comp = compSize > 0 ? Buffer.allocUnsafe(compSize) : Buffer.alloc(0);
|
|
160
|
+
if (compSize > 0) fs.readSync(fd, comp, 0, compSize, dataOff);
|
|
161
|
+
if (method === 0) out[name] = comp; // stored
|
|
162
|
+
else if (method === 8) out[name] = inflateSync(comp, { out: new Uint8Array(uncompSize) }); // deflate
|
|
163
|
+
else throw new Error("compression method " + method);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
93
168
|
/**
|
|
94
169
|
* Resolve a single archive's own coordinate + confidence from its entries.
|
|
95
170
|
* Returns { coord, source } | null. `source` ∈ pom.properties|manifest|filename.
|
|
@@ -115,24 +190,14 @@ function coordForArchive(entries, fileName) {
|
|
|
115
190
|
}
|
|
116
191
|
|
|
117
192
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
193
|
+
* Given an archive's already-extracted entries, record its coordinate and recurse
|
|
194
|
+
* into any nested archives. `ctx` carries { out, warnings, onProgress, scanned }.
|
|
120
195
|
*/
|
|
121
|
-
function
|
|
122
|
-
if (depth > MAX_DEPTH) { warnings.push({ type: "embedded-jar", message: `nesting too deep, skipped: ${displayPath}` }); return; }
|
|
123
|
-
if (buf.length > MAX_ARCHIVE_BYTES) { warnings.push({ type: "embedded-jar", message: `archive too large (${Math.round(buf.length / 1048576)}MB), skipped: ${displayPath}` }); return; }
|
|
124
|
-
|
|
125
|
-
// Read only what we need: this archive's identity files + any nested archives.
|
|
126
|
-
const entries = readEntries(buf, name =>
|
|
127
|
-
/^META-INF\/maven\/[^/]+\/[^/]+\/pom\.properties$/.test(name) ||
|
|
128
|
-
name === "META-INF/MANIFEST.MF" ||
|
|
129
|
-
ARCHIVE_RE.test(name));
|
|
130
|
-
if (entries === null) { warnings.push({ type: "embedded-jar", message: `could not read archive (corrupt/unsupported): ${displayPath}` }); return; }
|
|
131
|
-
|
|
196
|
+
function processEntries(entries, displayPath, fileName, ctx, depth) {
|
|
132
197
|
const resolved = coordForArchive(entries, fileName);
|
|
133
198
|
if (resolved) {
|
|
134
|
-
const { coord
|
|
135
|
-
out.push(makeDepRecord({
|
|
199
|
+
const { coord } = resolved;
|
|
200
|
+
ctx.out.push(makeDepRecord({
|
|
136
201
|
ecosystem: "maven",
|
|
137
202
|
namespace: coord.groupId || "",
|
|
138
203
|
name: coord.artifactId,
|
|
@@ -142,7 +207,7 @@ function scanArchiveBuffer(buf, displayPath, fileName, out, warnings, depth) {
|
|
|
142
207
|
provenance: "embedded",
|
|
143
208
|
}));
|
|
144
209
|
} else {
|
|
145
|
-
warnings.push({ type: "embedded-jar", message: `embedded archive with no resolvable Maven coordinate (not scanned): ${displayPath}` });
|
|
210
|
+
ctx.warnings.push({ type: "embedded-jar", message: `embedded archive with no resolvable Maven coordinate (not scanned): ${displayPath}` });
|
|
146
211
|
}
|
|
147
212
|
|
|
148
213
|
// Recurse into nested archives (fat-jar libs).
|
|
@@ -150,10 +215,62 @@ function scanArchiveBuffer(buf, displayPath, fileName, out, warnings, depth) {
|
|
|
150
215
|
if (!ARCHIVE_RE.test(name)) continue;
|
|
151
216
|
const nestedBuf = entries[name];
|
|
152
217
|
if (!nestedBuf || !nestedBuf.length) continue;
|
|
153
|
-
scanArchiveBuffer(Buffer.from(nestedBuf), `${displayPath}!/${name}`, path.basename(name),
|
|
218
|
+
scanArchiveBuffer(Buffer.from(nestedBuf), `${displayPath}!/${name}`, path.basename(name), ctx, depth + 1);
|
|
154
219
|
}
|
|
155
220
|
}
|
|
156
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Recursively scan one archive held in memory (a nested fat-jar lib). Only the
|
|
224
|
+
* coordinate/manifest/nested-archive entries are decompressed (via wantedEntry).
|
|
225
|
+
* `displayPath` is the `!/`-joined logical path.
|
|
226
|
+
*/
|
|
227
|
+
function scanArchiveBuffer(buf, displayPath, fileName, ctx, depth) {
|
|
228
|
+
if (depth > MAX_DEPTH) { ctx.warnings.push({ type: "embedded-jar", message: `nesting too deep, skipped: ${displayPath}` }); return; }
|
|
229
|
+
if (buf.length > MAX_ARCHIVE_BYTES) { ctx.warnings.push({ type: "embedded-jar", message: `archive too large (${Math.round(buf.length / 1048576)}MB), skipped: ${displayPath}` }); return; }
|
|
230
|
+
// Report before the (blocking) inflate so the displayed line names this archive.
|
|
231
|
+
ctx.scanned++;
|
|
232
|
+
ctx.onProgress?.({ phase: "scan", scanned: ctx.scanned, total: ctx.total, path: displayPath });
|
|
233
|
+
|
|
234
|
+
const entries = readEntries(buf, wantedEntry);
|
|
235
|
+
if (entries === null) { ctx.warnings.push({ type: "embedded-jar", message: `could not read archive (corrupt/unsupported): ${displayPath}` }); return; }
|
|
236
|
+
processEntries(entries, displayPath, fileName, ctx, depth);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Scan one TOP-LEVEL on-disk archive. Tries the lazy positioned-read path first
|
|
241
|
+
* (reads only the central directory + wanted entries — never the whole file), and
|
|
242
|
+
* falls back to reading the whole file + fflate on any case the lazy parser can't
|
|
243
|
+
* handle (ZIP64, encryption, corruption).
|
|
244
|
+
*/
|
|
245
|
+
function scanArchiveFile(abs, displayPath, fileName, ctx) {
|
|
246
|
+
ctx.scanned++;
|
|
247
|
+
ctx.onProgress?.({ phase: "scan", scanned: ctx.scanned, total: ctx.total, path: displayPath });
|
|
248
|
+
|
|
249
|
+
let size;
|
|
250
|
+
try { size = fs.statSync(abs).size; }
|
|
251
|
+
catch (e) { ctx.warnings.push({ type: "embedded-jar", message: `could not stat ${abs}: ${e.message}` }); return; }
|
|
252
|
+
// Skip oversized archives WITHOUT pulling them into RAM.
|
|
253
|
+
if (size > MAX_ARCHIVE_BYTES) { ctx.warnings.push({ type: "embedded-jar", message: `archive too large (${Math.round(size / 1048576)}MB), skipped: ${displayPath}` }); return; }
|
|
254
|
+
|
|
255
|
+
// Fast path: lazy central-directory read (no whole-file load, no .class inflate).
|
|
256
|
+
let fd;
|
|
257
|
+
try {
|
|
258
|
+
fd = fs.openSync(abs, "r");
|
|
259
|
+
const entries = readZipEntriesFromFd(fd, size, wantedEntry);
|
|
260
|
+
processEntries(entries, displayPath, fileName, ctx, 0);
|
|
261
|
+
return;
|
|
262
|
+
} catch { /* fall back to the whole-buffer path below */ }
|
|
263
|
+
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } } }
|
|
264
|
+
|
|
265
|
+
// Fallback: read the whole file + fflate (handles ZIP64 etc.).
|
|
266
|
+
let buf;
|
|
267
|
+
try { buf = fs.readFileSync(abs); }
|
|
268
|
+
catch (e) { ctx.warnings.push({ type: "embedded-jar", message: `could not read ${abs}: ${e.message}` }); return; }
|
|
269
|
+
const entries = readEntries(buf, wantedEntry);
|
|
270
|
+
if (entries === null) { ctx.warnings.push({ type: "embedded-jar", message: `could not read archive (corrupt/unsupported): ${displayPath}` }); return; }
|
|
271
|
+
processEntries(entries, displayPath, fileName, ctx, 0);
|
|
272
|
+
}
|
|
273
|
+
|
|
157
274
|
/** Recursively list archive file paths under `dir`. */
|
|
158
275
|
function findArchives(dir, acc = []) {
|
|
159
276
|
let entries;
|
|
@@ -170,22 +287,49 @@ function findArchives(dir, acc = []) {
|
|
|
170
287
|
return acc;
|
|
171
288
|
}
|
|
172
289
|
|
|
290
|
+
// Parallel equivalent of findArchives — concurrent readdir so a deep tree on a
|
|
291
|
+
// high-latency filesystem isn't walked one round-trip at a time.
|
|
292
|
+
async function findArchivesAsync(dir) {
|
|
293
|
+
const { walkDirs } = require("../../parallel-walk");
|
|
294
|
+
const acc = [];
|
|
295
|
+
await walkDirs(dir, {
|
|
296
|
+
skipDir: name => SKIP_DIRS.has(name) || name.startsWith("."),
|
|
297
|
+
onDir: (cur, entries) => {
|
|
298
|
+
for (const e of entries) if (e.isFile() && ARCHIVE_RE.test(e.name)) acc.push(path.join(cur, e.name));
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
return acc;
|
|
302
|
+
}
|
|
303
|
+
|
|
173
304
|
/**
|
|
174
305
|
* Scan `rootDir` for embedded JAR/WAR/EAR coordinates.
|
|
306
|
+
*
|
|
307
|
+
* Reading + unzipping is synchronous and can be slow on a tree with many or large
|
|
308
|
+
* fat-jars, so `opts.onProgress` is invoked around the work — for EVERY archive,
|
|
309
|
+
* nested ones included — so the caller can tell the user what's happening (it would
|
|
310
|
+
* otherwise block silently, especially while recursing through a fat-jar's libs):
|
|
311
|
+
* onProgress({ phase: "start", total }) once; total = top-level archive count
|
|
312
|
+
* onProgress({ phase: "scan", scanned, total, path }) before each archive is read (top-level + nested)
|
|
313
|
+
* onProgress({ phase: "done", total, found, scanned }) once, when finished
|
|
314
|
+
*
|
|
175
315
|
* @returns { deps: depRecord[], warnings: [{type,message}] }
|
|
176
316
|
*/
|
|
177
|
-
function scanEmbeddedJars(rootDir, opts = {}) {
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
317
|
+
async function scanEmbeddedJars(rootDir, opts = {}) {
|
|
318
|
+
const archives = await findArchivesAsync(rootDir);
|
|
319
|
+
const ctx = {
|
|
320
|
+
out: [],
|
|
321
|
+
warnings: [],
|
|
322
|
+
onProgress: typeof opts.onProgress === "function" ? opts.onProgress : null,
|
|
323
|
+
scanned: 0,
|
|
324
|
+
total: archives.length,
|
|
325
|
+
};
|
|
326
|
+
ctx.onProgress?.({ phase: "start", total: archives.length });
|
|
181
327
|
for (const abs of archives) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
catch (e) { warnings.push({ type: "embedded-jar", message: `could not read ${abs}: ${e.message}` }); continue; }
|
|
185
|
-
const rel = opts.srcRoot ? path.relative(opts.srcRoot, abs) : abs;
|
|
186
|
-
scanArchiveBuffer(buf, rel.split(path.sep).join("/"), path.basename(abs), out, warnings, 0);
|
|
328
|
+
const rel = (opts.srcRoot ? path.relative(opts.srcRoot, abs) : abs).split(path.sep).join("/");
|
|
329
|
+
scanArchiveFile(abs, rel, path.basename(abs), ctx);
|
|
187
330
|
}
|
|
188
|
-
|
|
331
|
+
ctx.onProgress?.({ phase: "done", total: archives.length, found: ctx.out.length, scanned: ctx.scanned });
|
|
332
|
+
return { deps: ctx.out, warnings: ctx.warnings };
|
|
189
333
|
}
|
|
190
334
|
|
|
191
335
|
module.exports = {
|
|
@@ -196,4 +340,6 @@ module.exports = {
|
|
|
196
340
|
coordFromFilename,
|
|
197
341
|
coordForArchive,
|
|
198
342
|
scanArchiveBuffer,
|
|
343
|
+
readZipEntriesFromFd,
|
|
344
|
+
findArchivesAsync,
|
|
199
345
|
};
|
|
@@ -40,7 +40,7 @@ module.exports = {
|
|
|
40
40
|
// Expose _maven (store/propsByPom/pomFiles) pour que l'orchestrateur garde la
|
|
41
41
|
// phase de réécriture des POM nettoyés (lib/core.rewritePoms).
|
|
42
42
|
async collect(dir, opts = {}) {
|
|
43
|
-
const pomFiles = core.
|
|
43
|
+
const pomFiles = await core.findPomFilesAsync(dir);
|
|
44
44
|
const store = core.newMetadataStore();
|
|
45
45
|
const propsByPom = {};
|
|
46
46
|
for (const pom of pomFiles) {
|
|
@@ -58,7 +58,7 @@ module.exports = {
|
|
|
58
58
|
if (opts.scanJars !== false) {
|
|
59
59
|
try {
|
|
60
60
|
const { scanEmbeddedJars } = require("./maven/jar-scan");
|
|
61
|
-
const { deps: embedded, warnings: jarWarnings } = scanEmbeddedJars(dir, { srcRoot: opts.srcRoot || dir });
|
|
61
|
+
const { deps: embedded, warnings: jarWarnings } = await scanEmbeddedJars(dir, { srcRoot: opts.srcRoot || dir, onProgress: opts.onJarProgress });
|
|
62
62
|
for (const rec of embedded) deps.set(rec.coordKey, rec);
|
|
63
63
|
warnings.push(...jarWarnings);
|
|
64
64
|
} catch (e) { warnings.push({ type: "embedded-jar", message: `embedded-jar scan failed: ${e.message}` }); }
|
|
@@ -137,7 +137,9 @@ function collectNpmDeps(rootDir, opts = {}) {
|
|
|
137
137
|
const { ignoreTest, deps2Exclude, verbose } = opts;
|
|
138
138
|
const out = new Map();
|
|
139
139
|
const warnings = [];
|
|
140
|
-
|
|
140
|
+
// Caller may pass pre-discovered manifest groups (e.g. from the parallel
|
|
141
|
+
// findJsManifestsAsync walk) to avoid a second, serial tree traversal.
|
|
142
|
+
const manifestGroups = opts.manifestGroups || findJsManifests(rootDir);
|
|
141
143
|
let parsedCount = 0;
|
|
142
144
|
|
|
143
145
|
for (const group of manifestGroups) {
|
package/lib/codecs/npm/parse.js
CHANGED
|
@@ -388,6 +388,30 @@ function findJsManifests(rootDir, opts = {}) {
|
|
|
388
388
|
return found;
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
+
// Parallel equivalent of findJsManifests — concurrent readdir so the walk isn't
|
|
392
|
+
// serialized one round-trip at a time on a high-latency filesystem.
|
|
393
|
+
async function findJsManifestsAsync(rootDir, opts = {}) {
|
|
394
|
+
const { skipDirs = DEFAULT_JS_SKIP_DIRS } = opts;
|
|
395
|
+
const { walkDirs } = require("../../parallel-walk");
|
|
396
|
+
const found = [];
|
|
397
|
+
await walkDirs(rootDir, {
|
|
398
|
+
skipDir: name => skipDirs.has(name),
|
|
399
|
+
onDir: (cur, entries) => {
|
|
400
|
+
const here = { dir: cur, packageJson: null, packageLock: null, yarnLock: null, pnpmLock: null };
|
|
401
|
+
for (const e of entries) {
|
|
402
|
+
if (!e.isFile()) continue;
|
|
403
|
+
const p = path.join(cur, e.name);
|
|
404
|
+
if (e.name === "package.json") here.packageJson = p;
|
|
405
|
+
else if (e.name === "package-lock.json") here.packageLock = p;
|
|
406
|
+
else if (e.name === "yarn.lock") here.yarnLock = p;
|
|
407
|
+
else if (e.name === "pnpm-lock.yaml") here.pnpmLock = p;
|
|
408
|
+
}
|
|
409
|
+
if (here.packageJson || here.packageLock || here.yarnLock || here.pnpmLock) found.push(here);
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
return found;
|
|
413
|
+
}
|
|
414
|
+
|
|
391
415
|
module.exports = {
|
|
392
416
|
parsePackageJson,
|
|
393
417
|
parsePackageLock,
|
|
@@ -395,4 +419,5 @@ module.exports = {
|
|
|
395
419
|
parseYarnBerry,
|
|
396
420
|
parsePnpmLock,
|
|
397
421
|
findJsManifests,
|
|
422
|
+
findJsManifestsAsync,
|
|
398
423
|
};
|
package/lib/codecs/npm.codec.js
CHANGED
|
@@ -49,7 +49,11 @@ module.exports = {
|
|
|
49
49
|
recipe: require("./recipes").npm,
|
|
50
50
|
// collect ramasse TOUTES les deps JS (package-lock + yarn.lock).
|
|
51
51
|
async collect(dir, opts = {}) {
|
|
52
|
-
|
|
52
|
+
// Parallel manifest discovery (concurrent readdir) — much faster than the
|
|
53
|
+
// serial walk on a high-latency filesystem; parsing then runs synchronously.
|
|
54
|
+
const { findJsManifestsAsync } = require("./npm/parse");
|
|
55
|
+
const manifestGroups = await findJsManifestsAsync(dir);
|
|
56
|
+
const deps = collectNpmDeps(dir, { ...opts, manifestGroups });
|
|
53
57
|
return { deps, warnings: deps.warnings || [] };
|
|
54
58
|
},
|
|
55
59
|
};
|
package/lib/core.js
CHANGED
|
@@ -39,6 +39,20 @@ function findPomFiles(dir) {
|
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Parallel equivalent of findPomFiles — same result, but readdir runs concurrently
|
|
43
|
+
// so the walk isn't serialized one round-trip at a time on a high-latency filesystem.
|
|
44
|
+
async function findPomFilesAsync(dir) {
|
|
45
|
+
const { walkDirs } = require("./parallel-walk");
|
|
46
|
+
const out = [];
|
|
47
|
+
await walkDirs(dir, {
|
|
48
|
+
skipDir: name => SKIP_DIRS.has(name),
|
|
49
|
+
onDir: (cur, entries) => {
|
|
50
|
+
for (const e of entries) if (e.isFile() && e.name === "pom.xml") out.push(path.join(cur, e.name));
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
function newMetadataStore() {
|
|
43
57
|
return {
|
|
44
58
|
byPath: {},
|
|
@@ -346,6 +360,7 @@ async function rewritePoms(pomPath, allPomMetadata, allPropsByPom, opts) {
|
|
|
346
360
|
module.exports = {
|
|
347
361
|
coord,
|
|
348
362
|
findPomFiles,
|
|
363
|
+
findPomFilesAsync,
|
|
349
364
|
newMetadataStore,
|
|
350
365
|
parsePom,
|
|
351
366
|
resolveParentPath,
|
package/lib/cve-report.js
CHANGED
|
@@ -17,7 +17,9 @@ const { computePriority, sortByPriority } = require("./priority");
|
|
|
17
17
|
// back to the raw id (with a "—" placeholder name where the row asks for one).
|
|
18
18
|
const CWE_NAMES = (() => {
|
|
19
19
|
try {
|
|
20
|
-
|
|
20
|
+
// require() (not fs.readFileSync) so bun --compile bundles the data file
|
|
21
|
+
// into the standalone binary instead of reading it off disk at runtime.
|
|
22
|
+
const raw = { ...require("../data/cwe-names.json") };
|
|
21
23
|
delete raw._comment;
|
|
22
24
|
return raw;
|
|
23
25
|
} catch { return {}; }
|