fad-checker 2.1.1 → 2.2.0
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/CHANGELOG.md +37 -0
- package/README.md +100 -32
- package/fad-checker-report/cve-report.doc +643 -105
- package/fad-checker-report/cve-report.html +653 -108
- package/fad-checker.js +265 -55
- package/lib/codecs/binary/scan.js +71 -0
- package/lib/codecs/binary/sniff.js +37 -0
- package/lib/codecs/binary.codec.js +54 -0
- package/lib/codecs/composer.codec.js +7 -3
- package/lib/codecs/go/registry.js +19 -11
- package/lib/codecs/go.codec.js +7 -3
- package/lib/codecs/index.js +73 -4
- package/lib/codecs/maven/jar-scan.js +179 -28
- package/lib/codecs/maven.codec.js +4 -2
- package/lib/codecs/npm/collect.js +3 -1
- package/lib/codecs/npm/parse.js +29 -1
- package/lib/codecs/npm/registry.js +29 -18
- package/lib/codecs/npm.codec.js +10 -4
- package/lib/codecs/nuget.codec.js +7 -3
- package/lib/codecs/pypi/registry.js +16 -10
- package/lib/codecs/pypi.codec.js +7 -3
- package/lib/codecs/recipes.js +9 -1
- package/lib/codecs/ruby/registry.js +16 -10
- package/lib/codecs/ruby.codec.js +7 -3
- package/lib/config.js +34 -23
- package/lib/core.js +22 -3
- package/lib/cve-match.js +3 -2
- package/lib/cve-report.js +59 -6
- package/lib/dep-record.js +12 -9
- package/lib/hash-id.js +78 -0
- package/lib/json-export.js +8 -1
- package/lib/license-policy.js +3 -1
- package/lib/options-env.js +113 -0
- package/lib/outdated.js +3 -0
- package/lib/parallel-walk.js +50 -0
- package/lib/path-filter.js +58 -0
- package/lib/registries.js +112 -0
- package/lib/retire.js +66 -0
- package/lib/scan-completeness.js +7 -1
- package/lib/unmanaged.js +82 -0
- package/package.json +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/binary/sniff.js — file-type confirmation for the binary codec.
|
|
3
|
+
*
|
|
4
|
+
* Two gates: extKind() (extension allowlist) AND sniffKind() (magic bytes). A
|
|
5
|
+
* candidate is accepted only when both agree, so an image renamed `.so` (PNG
|
|
6
|
+
* magic) is rejected. We never trust the extension alone.
|
|
7
|
+
*
|
|
8
|
+
* @author: N.BRAUN
|
|
9
|
+
* @email: pp9ping@gmail.com
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Magic-byte signatures → family. Mach-O has four (32/64-bit + fat, both endian).
|
|
13
|
+
function sniffKind(buf) {
|
|
14
|
+
if (!buf || buf.length < 4) return null;
|
|
15
|
+
const b0 = buf[0], b1 = buf[1], b2 = buf[2], b3 = buf[3];
|
|
16
|
+
if (b0 === 0x4d && b1 === 0x5a) return "pe"; // MZ
|
|
17
|
+
if (b0 === 0x7f && b1 === 0x45 && b2 === 0x4c && b3 === 0x46) return "elf"; // \x7FELF
|
|
18
|
+
if (b0 === 0x50 && b1 === 0x4b && b2 === 0x03 && b3 === 0x04) return "zip"; // PK\x03\x04
|
|
19
|
+
const be = ((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) >>> 0;
|
|
20
|
+
const le = ((b3 << 24) | (b2 << 16) | (b1 << 8) | b0) >>> 0;
|
|
21
|
+
const machO = new Set([0xfeedface, 0xfeedfacf, 0xcafebabe, 0xcafebabf]);
|
|
22
|
+
if (machO.has(be) || machO.has(le)) return "macho";
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EXT_PE = /\.(dll|exe)$/i;
|
|
27
|
+
const EXT_MACHO = /\.dylib$/i;
|
|
28
|
+
const EXT_ELF = /\.so(\.\d+)*$/i; // .so, .so.1, .so.1.2.3
|
|
29
|
+
|
|
30
|
+
function extKind(name) {
|
|
31
|
+
if (EXT_PE.test(name)) return "pe";
|
|
32
|
+
if (EXT_MACHO.test(name)) return "macho";
|
|
33
|
+
if (EXT_ELF.test(name)) return "elf";
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { sniffKind, extKind };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/binary.codec.js — codec for committed NATIVE binaries
|
|
3
|
+
* (.dll/.exe/.so/.dylib) that no package manager governs.
|
|
4
|
+
*
|
|
5
|
+
* Plan 1 scope: discover + hash only. The records carry no resolved coordinate
|
|
6
|
+
* (just a filename); Plan 2's hash-id service fills `identity`, Plan 3 builds the
|
|
7
|
+
* unmanaged inventory + report. Until then the records are `provenance:"binary"`
|
|
8
|
+
* and the CVE/OSV/EOL/outdated stages skip them (no coordinate to query).
|
|
9
|
+
*
|
|
10
|
+
* @author: N.BRAUN
|
|
11
|
+
* @email: pp9ping@gmail.com
|
|
12
|
+
*/
|
|
13
|
+
const { makeDepRecord } = require("../dep-record");
|
|
14
|
+
const { scanBinaries } = require("./binary/scan");
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
id: "binary",
|
|
18
|
+
label: "Binaries",
|
|
19
|
+
osvEcosystem: null, // no OSV ecosystem until identified
|
|
20
|
+
manifestNames: ["*.dll", "*.exe", "*.so", "*.dylib"], // lets detectCodecs include us in auto mode
|
|
21
|
+
|
|
22
|
+
detect(dir) {
|
|
23
|
+
try { return scanBinaries(dir, { onProgress: null }).length > 0; }
|
|
24
|
+
catch { return false; }
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async collect(dir, opts = {}) {
|
|
28
|
+
const out = new Map();
|
|
29
|
+
const warnings = [];
|
|
30
|
+
let records;
|
|
31
|
+
try { records = scanBinaries(dir, { onProgress: opts.onBinaryProgress, srcRoot: opts.srcRoot || dir, excludePath: opts.excludePath, defaultExcludes: opts.defaultExcludes }); }
|
|
32
|
+
catch (e) { return { deps: out, warnings: [{ type: "scan-error", message: `binary scan failed: ${e.message}` }] }; }
|
|
33
|
+
for (const r of records) {
|
|
34
|
+
out.set(`binary:${r.path}`, makeDepRecord({
|
|
35
|
+
ecosystem: "binary",
|
|
36
|
+
name: r.declaredName,
|
|
37
|
+
version: null,
|
|
38
|
+
manifestPath: r.path,
|
|
39
|
+
provenance: "binary",
|
|
40
|
+
hashes: { sha1: r.sha1, sha256: r.sha256 },
|
|
41
|
+
declaredName: r.declaredName,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
return { deps: out, warnings };
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
coordKey(d) { return `binary:${d.manifestPaths?.[0] || d.name}`; },
|
|
48
|
+
formatCoord(d) { return d.declaredName || d.name; },
|
|
49
|
+
osvPackageName() { return null; },
|
|
50
|
+
async checkRegistry() { return { deprecated: [], outdated: [], licensed: [] }; },
|
|
51
|
+
resolveEolProduct() { return null; },
|
|
52
|
+
recipe: require("./recipes").binary,
|
|
53
|
+
nativeScanners: [],
|
|
54
|
+
};
|
|
@@ -15,7 +15,7 @@ const { parseComposerLock, parseComposerJson, isConcrete } = require("./composer
|
|
|
15
15
|
|
|
16
16
|
const SKIP = new Set(["vendor", ".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target"]);
|
|
17
17
|
|
|
18
|
-
function findComposerManifests(dir) {
|
|
18
|
+
function findComposerManifests(dir, skipDir = (child, name) => SKIP.has(name)) {
|
|
19
19
|
const groups = [];
|
|
20
20
|
const stack = [dir];
|
|
21
21
|
while (stack.length) {
|
|
@@ -29,11 +29,15 @@ function findComposerManifests(dir) {
|
|
|
29
29
|
composerLock: names.has("composer.lock") ? path.join(cur, "composer.lock") : null,
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
|
-
for (const e of entries) if (e.isDirectory() && !
|
|
32
|
+
for (const e of entries) if (e.isDirectory() && !skipDir(path.join(cur, e.name), e.name)) stack.push(path.join(cur, e.name));
|
|
33
33
|
}
|
|
34
34
|
return groups;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function dirFilter(dir, opts) {
|
|
38
|
+
return require("../path-filter").makeDirFilter({ srcRoot: opts.srcRoot || dir, defaultSkip: SKIP, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
|
|
39
|
+
}
|
|
40
|
+
|
|
37
41
|
module.exports = {
|
|
38
42
|
id: "composer",
|
|
39
43
|
label: "Composer",
|
|
@@ -46,7 +50,7 @@ module.exports = {
|
|
|
46
50
|
const { ignoreTest, deps2Exclude } = opts;
|
|
47
51
|
const out = new Map();
|
|
48
52
|
const warnings = [];
|
|
49
|
-
for (const g of findComposerManifests(dir)) {
|
|
53
|
+
for (const g of findComposerManifests(dir, dirFilter(dir, opts))) {
|
|
50
54
|
if (g.composerLock) {
|
|
51
55
|
let parsed;
|
|
52
56
|
try { parsed = parseComposerLock(g.composerLock); }
|
|
@@ -13,11 +13,11 @@ const fs = require("fs");
|
|
|
13
13
|
const path = require("path");
|
|
14
14
|
const os = require("os");
|
|
15
15
|
let pLimit; try { pLimit = require("p-limit"); } catch { pLimit = () => (fn) => fn(); }
|
|
16
|
+
const { withPublic, authHeaderFor, normalise } = require("../../registries");
|
|
16
17
|
|
|
17
18
|
const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
|
|
18
19
|
const CACHE_PATH = path.join(CACHE_DIR, "go-proxy-cache.json");
|
|
19
20
|
const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
|
|
20
|
-
const PROXY = "https://proxy.golang.org";
|
|
21
21
|
|
|
22
22
|
function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); } catch { return { entries: {}, meta: {} }; } }
|
|
23
23
|
function saveCache(d) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify(d)); } catch { /* ignore */ } }
|
|
@@ -34,18 +34,26 @@ function cmp(a, b) {
|
|
|
34
34
|
return 0;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// Custom GOPROXY first (same protocol: <base>/<escaped-module>/@latest), then proxy.golang.org.
|
|
38
|
+
async function fetchLatest(mod, { offline, registries = [], fetcher = globalThis.fetch } = {}) {
|
|
38
39
|
if (offline) return null;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
const bases = withPublic("go", registries);
|
|
41
|
+
let lastErr = null;
|
|
42
|
+
for (const base of bases) {
|
|
43
|
+
const url = normalise(base.url) + `${escapeModule(mod)}/@latest`;
|
|
44
|
+
const headers = { "User-Agent": "fad-checker-go", Accept: "application/json" };
|
|
45
|
+
const ah = authHeaderFor(base); if (ah) headers.Authorization = ah;
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetcher(url, { headers });
|
|
48
|
+
if (res.ok) { const j = await res.json(); return { latest: (j.Version || "").replace(/^v/, "") || null }; }
|
|
49
|
+
lastErr = `HTTP ${res.status}`;
|
|
50
|
+
} catch (e) { lastErr = e.message; }
|
|
51
|
+
}
|
|
52
|
+
return { error: lastErr || "no data" };
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
async function checkGoRegistryDeps(deps, opts = {}) {
|
|
48
|
-
const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
|
|
56
|
+
const { verbose, offline, allLibs = true, concurrency = 8, registries = [] } = opts;
|
|
49
57
|
const targets = [...deps.values()].filter(d => d.ecosystem === "go" && d.version);
|
|
50
58
|
const result = { deprecated: [], outdated: [], licensed: [] };
|
|
51
59
|
if (!targets.length || !allLibs) return result;
|
|
@@ -57,7 +65,7 @@ async function checkGoRegistryDeps(deps, opts = {}) {
|
|
|
57
65
|
const key = `${t.name}@${t.version}`;
|
|
58
66
|
let ex = cache.entries[key];
|
|
59
67
|
if (!ex) {
|
|
60
|
-
const r = await fetchLatest(t.name, { offline });
|
|
68
|
+
const r = await fetchLatest(t.name, { offline, registries });
|
|
61
69
|
ex = (r && !r.error) ? { latest: r.latest } : { latest: null };
|
|
62
70
|
if (r && !r.error) cache.entries[key] = ex;
|
|
63
71
|
}
|
|
@@ -71,4 +79,4 @@ async function checkGoRegistryDeps(deps, opts = {}) {
|
|
|
71
79
|
return result;
|
|
72
80
|
}
|
|
73
81
|
|
|
74
|
-
module.exports = { checkGoRegistryDeps, escapeModule };
|
|
82
|
+
module.exports = { checkGoRegistryDeps, escapeModule, fetchLatest };
|
package/lib/codecs/go.codec.js
CHANGED
|
@@ -15,7 +15,7 @@ const G = require("./go/parse");
|
|
|
15
15
|
|
|
16
16
|
const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target", "vendor", "testdata"]);
|
|
17
17
|
|
|
18
|
-
function findGoDirs(dir) {
|
|
18
|
+
function findGoDirs(dir, skipDir = (child, name) => SKIP.has(name)) {
|
|
19
19
|
const groups = [];
|
|
20
20
|
const stack = [dir];
|
|
21
21
|
while (stack.length) {
|
|
@@ -23,11 +23,15 @@ function findGoDirs(dir) {
|
|
|
23
23
|
let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
24
24
|
const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
|
|
25
25
|
if (names.has("go.mod") || names.has("go.sum")) groups.push({ dir: cur, names });
|
|
26
|
-
for (const e of entries) if (e.isDirectory() && !
|
|
26
|
+
for (const e of entries) if (e.isDirectory() && !skipDir(path.join(cur, e.name), e.name)) stack.push(path.join(cur, e.name));
|
|
27
27
|
}
|
|
28
28
|
return groups;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function dirFilter(dir, opts) {
|
|
32
|
+
return require("../path-filter").makeDirFilter({ srcRoot: opts.srcRoot || dir, defaultSkip: SKIP, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
|
|
33
|
+
}
|
|
34
|
+
|
|
31
35
|
module.exports = {
|
|
32
36
|
id: "go",
|
|
33
37
|
label: "Go",
|
|
@@ -44,7 +48,7 @@ module.exports = {
|
|
|
44
48
|
if (deps2Exclude && deps2Exclude.test(d.name)) return;
|
|
45
49
|
out.set(coordKeyFor("go", "", d.name), makeDepRecord({ ecosystem: "go", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: false }));
|
|
46
50
|
};
|
|
47
|
-
for (const g of findGoDirs(dir)) {
|
|
51
|
+
for (const g of findGoDirs(dir, dirFilter(dir, opts))) {
|
|
48
52
|
if (g.names.has("go.mod")) {
|
|
49
53
|
const fp = path.join(g.dir, "go.mod");
|
|
50
54
|
try {
|
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");
|
|
@@ -17,12 +19,14 @@ const pypi = require("./pypi.codec");
|
|
|
17
19
|
const nuget = require("./nuget.codec");
|
|
18
20
|
const go = require("./go.codec");
|
|
19
21
|
const ruby = require("./ruby.codec");
|
|
22
|
+
const binary = require("./binary.codec");
|
|
20
23
|
|
|
21
24
|
// Ordre stable pour le report (maven, npm, yarn, puis les nouveaux écosystèmes).
|
|
22
|
-
|
|
25
|
+
// "binary" (committed native libs, no manifest) reste en dernier.
|
|
26
|
+
const ORDER = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby", "binary"];
|
|
23
27
|
|
|
24
28
|
const REGISTRY = new Map();
|
|
25
|
-
for (const c of [maven, npm, yarn, composer, pypi, nuget, go, ruby]) {
|
|
29
|
+
for (const c of [maven, npm, yarn, composer, pypi, nuget, go, ruby, binary]) {
|
|
26
30
|
assertCodecShape(c);
|
|
27
31
|
REGISTRY.set(c.id, c);
|
|
28
32
|
}
|
|
@@ -33,10 +37,75 @@ function allCodecs() {
|
|
|
33
37
|
return [...REGISTRY.values()].sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id));
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
// Dirs skipped during detection. Starts from the INTERSECTION of every codec's own
|
|
41
|
+
// skip set (a dir skipped here only if ALL codecs skip it → detection never misses a
|
|
42
|
+
// manifest a codec would have found) plus well-known dependency/cache/tooling dirs
|
|
43
|
+
// that are NEVER a project root (all dot-dirs or vendored-dep caches), so detection
|
|
44
|
+
// can prune them safely. NOTE: "build" / "vendor" / "bin" / "obj" are deliberately
|
|
45
|
+
// NOT here — maven keeps build/ for BOMs and several codecs scan vendor/.
|
|
46
|
+
const DETECT_SKIP = new Set([
|
|
47
|
+
"node_modules", ".git", ".idea", ".vscode", "target", "dist", "out", // intersection
|
|
48
|
+
".svn", ".hg", ".gradle", ".mvn", ".cache", ".m2", "bower_components", // never a project root
|
|
49
|
+
"jspm_packages", "__pycache__", ".venv", ".tox", ".mypy_cache", "coverage", ".next", ".nuxt",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const DETECT_CONCURRENCY = 48;
|
|
53
|
+
|
|
36
54
|
// yarn est détecté via le même arbre JS que npm ; on ne le renvoie pas en
|
|
37
55
|
// doublon de détection (npm.collect ramasse déjà yarn.lock).
|
|
38
|
-
|
|
39
|
-
|
|
56
|
+
//
|
|
57
|
+
// ONE shared walk instead of one full-tree walk per codec: we descend the tree once
|
|
58
|
+
// and, per file, test it against each codec's manifestNames (supporting "*.ext"
|
|
59
|
+
// globs). Short-circuits as soon as every codec has matched. readdir runs with bounded
|
|
60
|
+
// concurrency — on a high-latency filesystem (WSL/9p, network mounts) the walk is
|
|
61
|
+
// latency-bound, so issuing many readdirs at once is far faster than a serial walk.
|
|
62
|
+
async function detectCodecs(dir, opts = {}) {
|
|
63
|
+
const { makeDirFilter } = require("../path-filter");
|
|
64
|
+
const skipDir = makeDirFilter({ srcRoot: dir, defaultSkip: DETECT_SKIP, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
|
|
65
|
+
const codecs = allCodecs().filter(c => c.id !== "yarn");
|
|
66
|
+
const matchers = codecs.map(c => {
|
|
67
|
+
const exact = new Set();
|
|
68
|
+
const exts = [];
|
|
69
|
+
for (const n of c.manifestNames || []) {
|
|
70
|
+
if (n.startsWith("*.")) exts.push(n.slice(1)); // "*.csproj" → ".csproj"
|
|
71
|
+
else exact.add(n);
|
|
72
|
+
}
|
|
73
|
+
return { codec: c, exact, exts, found: false };
|
|
74
|
+
});
|
|
75
|
+
let remaining = matchers.length;
|
|
76
|
+
const queue = [dir];
|
|
77
|
+
let active = 0;
|
|
78
|
+
|
|
79
|
+
await new Promise(resolve => {
|
|
80
|
+
let settled = false;
|
|
81
|
+
const finish = () => { if (!settled) { settled = true; resolve(); } };
|
|
82
|
+
const pump = () => {
|
|
83
|
+
if (settled) return;
|
|
84
|
+
if (remaining === 0) return finish();
|
|
85
|
+
if (queue.length === 0 && active === 0) return finish();
|
|
86
|
+
while (active < DETECT_CONCURRENCY && queue.length && remaining > 0) {
|
|
87
|
+
const cur = queue.pop();
|
|
88
|
+
active++;
|
|
89
|
+
fs.promises.readdir(cur, { withFileTypes: true }).then(entries => {
|
|
90
|
+
for (const e of entries) {
|
|
91
|
+
if (e.isDirectory()) {
|
|
92
|
+
const child = path.join(cur, e.name);
|
|
93
|
+
if (!skipDir(child, e.name)) queue.push(child);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!e.isFile()) continue;
|
|
97
|
+
const name = e.name;
|
|
98
|
+
for (const m of matchers) {
|
|
99
|
+
if (m.found) continue;
|
|
100
|
+
if (m.exact.has(name) || m.exts.some(ext => name.endsWith(ext))) { m.found = true; remaining--; }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}).catch(() => { /* unreadable dir → skip */ }).finally(() => { active--; pump(); });
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
pump();
|
|
107
|
+
});
|
|
108
|
+
return matchers.filter(m => m.found).map(m => m.codec);
|
|
40
109
|
}
|
|
41
110
|
|
|
42
111
|
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,54 @@ 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, skipDir) {
|
|
293
|
+
const { walkDirs } = require("../../parallel-walk");
|
|
294
|
+
const skip = skipDir || ((child, name) => SKIP_DIRS.has(name) || name.startsWith("."));
|
|
295
|
+
const acc = [];
|
|
296
|
+
await walkDirs(dir, {
|
|
297
|
+
skipDir: skip,
|
|
298
|
+
onDir: (cur, entries) => {
|
|
299
|
+
for (const e of entries) if (e.isFile() && ARCHIVE_RE.test(e.name)) acc.push(path.join(cur, e.name));
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
return acc;
|
|
303
|
+
}
|
|
304
|
+
|
|
173
305
|
/**
|
|
174
306
|
* Scan `rootDir` for embedded JAR/WAR/EAR coordinates.
|
|
307
|
+
*
|
|
308
|
+
* Reading + unzipping is synchronous and can be slow on a tree with many or large
|
|
309
|
+
* fat-jars, so `opts.onProgress` is invoked around the work — for EVERY archive,
|
|
310
|
+
* nested ones included — so the caller can tell the user what's happening (it would
|
|
311
|
+
* otherwise block silently, especially while recursing through a fat-jar's libs):
|
|
312
|
+
* onProgress({ phase: "start", total }) once; total = top-level archive count
|
|
313
|
+
* onProgress({ phase: "scan", scanned, total, path }) before each archive is read (top-level + nested)
|
|
314
|
+
* onProgress({ phase: "done", total, found, scanned }) once, when finished
|
|
315
|
+
*
|
|
175
316
|
* @returns { deps: depRecord[], warnings: [{type,message}] }
|
|
176
317
|
*/
|
|
177
|
-
function scanEmbeddedJars(rootDir, opts = {}) {
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
const
|
|
318
|
+
async function scanEmbeddedJars(rootDir, opts = {}) {
|
|
319
|
+
const { makeDirFilter } = require("../../path-filter");
|
|
320
|
+
const useDefaults = opts.defaultExcludes !== false;
|
|
321
|
+
const base = makeDirFilter({ srcRoot: opts.srcRoot || rootDir, defaultSkip: SKIP_DIRS, excludePath: opts.excludePath, useDefaults });
|
|
322
|
+
const skipDir = (child, name) => base(child, name) || (useDefaults && name.startsWith("."));
|
|
323
|
+
const archives = await findArchivesAsync(rootDir, skipDir);
|
|
324
|
+
const ctx = {
|
|
325
|
+
out: [],
|
|
326
|
+
warnings: [],
|
|
327
|
+
onProgress: typeof opts.onProgress === "function" ? opts.onProgress : null,
|
|
328
|
+
scanned: 0,
|
|
329
|
+
total: archives.length,
|
|
330
|
+
};
|
|
331
|
+
ctx.onProgress?.({ phase: "start", total: archives.length });
|
|
181
332
|
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);
|
|
333
|
+
const rel = (opts.srcRoot ? path.relative(opts.srcRoot, abs) : abs).split(path.sep).join("/");
|
|
334
|
+
scanArchiveFile(abs, rel, path.basename(abs), ctx);
|
|
187
335
|
}
|
|
188
|
-
|
|
336
|
+
ctx.onProgress?.({ phase: "done", total: archives.length, found: ctx.out.length, scanned: ctx.scanned });
|
|
337
|
+
return { deps: ctx.out, warnings: ctx.warnings };
|
|
189
338
|
}
|
|
190
339
|
|
|
191
340
|
module.exports = {
|
|
@@ -196,4 +345,6 @@ module.exports = {
|
|
|
196
345
|
coordFromFilename,
|
|
197
346
|
coordForArchive,
|
|
198
347
|
scanArchiveBuffer,
|
|
348
|
+
readZipEntriesFromFd,
|
|
349
|
+
findArchivesAsync,
|
|
199
350
|
};
|
|
@@ -40,7 +40,9 @@ 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
|
|
43
|
+
const { makeDirFilter } = require("../path-filter");
|
|
44
|
+
const skipDir = makeDirFilter({ srcRoot: opts.srcRoot || dir, defaultSkip: core.SKIP_DIRS, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
|
|
45
|
+
const pomFiles = await core.findPomFilesAsync(dir, skipDir);
|
|
44
46
|
const store = core.newMetadataStore();
|
|
45
47
|
const propsByPom = {};
|
|
46
48
|
for (const pom of pomFiles) {
|
|
@@ -58,7 +60,7 @@ module.exports = {
|
|
|
58
60
|
if (opts.scanJars !== false) {
|
|
59
61
|
try {
|
|
60
62
|
const { scanEmbeddedJars } = require("./maven/jar-scan");
|
|
61
|
-
const { deps: embedded, warnings: jarWarnings } = scanEmbeddedJars(dir, { srcRoot: opts.srcRoot || dir });
|
|
63
|
+
const { deps: embedded, warnings: jarWarnings } = await scanEmbeddedJars(dir, { srcRoot: opts.srcRoot || dir, excludePath: opts.excludePath, defaultExcludes: opts.defaultExcludes, onProgress: opts.onJarProgress });
|
|
62
64
|
for (const rec of embedded) deps.set(rec.coordKey, rec);
|
|
63
65
|
warnings.push(...jarWarnings);
|
|
64
66
|
} 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) {
|