fad-checker 2.1.2 → 2.2.1
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 +75 -87
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +1 -0
- package/fad-checker.js +211 -49
- 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 +9 -4
- package/lib/codecs/maven/jar-scan.js +8 -3
- package/lib/codecs/maven.codec.js +4 -2
- package/lib/codecs/npm/parse.js +5 -2
- package/lib/codecs/npm/registry.js +29 -18
- package/lib/codecs/npm.codec.js +7 -5
- 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 +9 -5
- package/lib/cve-match.js +3 -2
- package/lib/cve-report.js +121 -25
- package/lib/dep-record.js +12 -9
- package/lib/embedded.js +57 -0
- package/lib/hash-id.js +78 -0
- package/lib/json-export.js +15 -2
- package/lib/options-env.js +113 -0
- package/lib/outdated.js +19 -7
- package/lib/parallel-walk.js +5 -2
- package/lib/path-filter.js +58 -0
- package/lib/registries.js +112 -0
- package/lib/retire.js +158 -7
- package/lib/scan-completeness.js +22 -7
- package/lib/ui.js +4 -3
- package/lib/unmanaged.js +82 -0
- package/package.json +1 -1
package/fad-checker.js
CHANGED
|
@@ -25,6 +25,18 @@ const core = require("./lib/core");
|
|
|
25
25
|
// (from $bunfs/root) and crashes with ENOENT. Keeps the bun builds fully standalone.
|
|
26
26
|
const pkg = require("./package.json");
|
|
27
27
|
|
|
28
|
+
// -------- compiled-binary retire mode --------
|
|
29
|
+
// The bun-compiled single binary has no node_modules to spawn the retire CLI from,
|
|
30
|
+
// and an air-gapped box has no `retire` on PATH. So lib/retire.js re-execs THIS
|
|
31
|
+
// binary with __FAD_RETIRE__ set; here we hand off to the statically-bundled retire
|
|
32
|
+
// CLI (it self-runs, reading process.argv — bun's argv mirrors node's). The entire
|
|
33
|
+
// normal CLI body is gated behind `else` so fad's own commander setup never runs in
|
|
34
|
+
// retire mode (retire shares commander's singleton `program`). Lets vendored-JS
|
|
35
|
+
// scanning work fully offline from the one binary, no external retire needed.
|
|
36
|
+
if (process.env.__FAD_RETIRE__) {
|
|
37
|
+
require("retire/lib/cli.js");
|
|
38
|
+
} else {
|
|
39
|
+
|
|
28
40
|
// -------- bash/zsh completion shortcut (must run before required-options parse) --------
|
|
29
41
|
if (process.argv.includes("--completion")) {
|
|
30
42
|
const shellIdx = process.argv.indexOf("--completion") + 1;
|
|
@@ -61,8 +73,9 @@ if (process.argv.includes("--show-config")) {
|
|
|
61
73
|
const cfg = config.load();
|
|
62
74
|
const masked = { ...cfg };
|
|
63
75
|
if (masked.nvd_api_key) masked.nvd_api_key = masked.nvd_api_key.slice(0, 8) + "…" + masked.nvd_api_key.slice(-4);
|
|
64
|
-
if (
|
|
65
|
-
masked.
|
|
76
|
+
if (masked.registries && typeof masked.registries === "object") {
|
|
77
|
+
masked.registries = Object.fromEntries(Object.entries(masked.registries).map(([eco, list]) =>
|
|
78
|
+
[eco, (list || []).map(r => ({ ...r, auth: r.auth ? "***" : undefined, token: r.token ? "***" : undefined }))]));
|
|
66
79
|
}
|
|
67
80
|
console.log(JSON.stringify(masked, null, 2));
|
|
68
81
|
console.log(chalk.gray("Config file: " + config.CONFIG_PATH));
|
|
@@ -72,44 +85,58 @@ if (process.argv.includes("--show-config")) {
|
|
|
72
85
|
// -------- --add-repo / --remove-repo / --list-repos (run before program.parse) --------
|
|
73
86
|
if (process.argv.includes("--add-repo") || process.argv.includes("--remove-repo") || process.argv.includes("--list-repos")) {
|
|
74
87
|
const config = require("./lib/config");
|
|
88
|
+
const { SUPPORTED } = require("./lib/registries");
|
|
89
|
+
const ecoErr = eco => {
|
|
90
|
+
if (!SUPPORTED.includes(eco)) {
|
|
91
|
+
console.error(chalk.red(`❌ unknown ecosystem "${eco}". Supported: ${SUPPORTED.join(", ")}`));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
75
95
|
if (process.argv.includes("--list-repos")) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
96
|
+
const map = config.getRegistryMap();
|
|
97
|
+
const ecos = Object.keys(map).filter(e => (map[e] || []).length);
|
|
98
|
+
if (!ecos.length) {
|
|
99
|
+
console.log(chalk.gray("No custom registries configured (public registries are always the fallback)."));
|
|
79
100
|
} else {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
101
|
+
for (const eco of ecos) {
|
|
102
|
+
console.log(chalk.bold(`${eco} (tried in order, then public):`));
|
|
103
|
+
for (const r of map[eco]) {
|
|
104
|
+
const authMark = (r.auth || r.token) ? chalk.yellow(" [auth]") : "";
|
|
105
|
+
console.log(` • ${chalk.cyan(r.name)} → ${r.url}${authMark}`);
|
|
106
|
+
}
|
|
84
107
|
}
|
|
85
108
|
}
|
|
86
109
|
process.exit(0);
|
|
87
110
|
}
|
|
88
111
|
if (process.argv.includes("--add-repo")) {
|
|
89
112
|
const idx = process.argv.indexOf("--add-repo");
|
|
90
|
-
const name = process.argv[idx + 1];
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
console.error(
|
|
94
|
-
console.error("
|
|
95
|
-
console.error(" Optional auth: --add-repo nexus https://nexus.acme.com/repository/maven-public/ --auth user:pass");
|
|
113
|
+
const [eco, name, url] = [process.argv[idx + 1], process.argv[idx + 2], process.argv[idx + 3]];
|
|
114
|
+
if (!eco || !name || !url || [eco, name, url].some(a => a.startsWith("-"))) {
|
|
115
|
+
console.error(chalk.red("❌ --add-repo requires <ecosystem> <name> <url>"));
|
|
116
|
+
console.error(" Example: fad-checker --add-repo npm verdaccio https://npm.acme/ --token TOK");
|
|
117
|
+
console.error(" Maven: fad-checker --add-repo maven nexus https://nexus.acme/maven-public/ --auth user:pass");
|
|
96
118
|
process.exit(1);
|
|
97
119
|
}
|
|
120
|
+
ecoErr(eco);
|
|
98
121
|
const authIdx = process.argv.indexOf("--auth");
|
|
99
|
-
const
|
|
100
|
-
config.
|
|
101
|
-
|
|
122
|
+
const tokIdx = process.argv.indexOf("--token");
|
|
123
|
+
config.addRegistry(eco, name, url, {
|
|
124
|
+
auth: authIdx > -1 ? process.argv[authIdx + 1] : null,
|
|
125
|
+
token: tokIdx > -1 ? process.argv[tokIdx + 1] : null,
|
|
126
|
+
});
|
|
127
|
+
console.log(chalk.green(`✅ Added ${eco} registry "${name}" → ${url}`));
|
|
102
128
|
process.exit(0);
|
|
103
129
|
}
|
|
104
130
|
if (process.argv.includes("--remove-repo")) {
|
|
105
131
|
const idx = process.argv.indexOf("--remove-repo");
|
|
106
|
-
const name = process.argv[idx + 1];
|
|
107
|
-
if (!name || name.startsWith("-")) {
|
|
108
|
-
console.error(chalk.red("❌ --remove-repo requires <name>"));
|
|
132
|
+
const [eco, name] = [process.argv[idx + 1], process.argv[idx + 2]];
|
|
133
|
+
if (!eco || !name || [eco, name].some(a => a.startsWith("-"))) {
|
|
134
|
+
console.error(chalk.red("❌ --remove-repo requires <ecosystem> <name>"));
|
|
109
135
|
process.exit(1);
|
|
110
136
|
}
|
|
111
|
-
|
|
112
|
-
|
|
137
|
+
ecoErr(eco);
|
|
138
|
+
const removed = config.removeRegistry(eco, name);
|
|
139
|
+
console.log(removed ? chalk.green(`✅ Removed ${eco} registry "${name}"`) : chalk.yellow(`⚠️ No ${eco} registry named "${name}"`));
|
|
113
140
|
process.exit(removed ? 0 : 1);
|
|
114
141
|
}
|
|
115
142
|
}
|
|
@@ -169,7 +196,11 @@ program
|
|
|
169
196
|
.option("-t, --target <target>", "output directory (will be rm before written). If omitted, the run is read-only.")
|
|
170
197
|
// Not a requiredOption: --import-anonymized scans a descriptor with no source tree.
|
|
171
198
|
.option("-s, --src <src>", "root directory containing pom.xml files")
|
|
172
|
-
.option("
|
|
199
|
+
.option("--source <src>", "alias of --src (also the JSON config key 'source')")
|
|
200
|
+
.option("--config <file>", "load default options from a JSON config file (else ./.fad-env.json)")
|
|
201
|
+
.option("-e, --exclude <exclude>", "regex of groupId/name to exclude, e.g. '^(client|private)\\.'")
|
|
202
|
+
.option("--exclude-path <glob...>", "ignore sub-paths during the walk (gitignore-style glob, relative to --src). Repeatable. e.g. 'packages/legacy/**' '**/fixtures/**'")
|
|
203
|
+
.option("--no-default-excludes", "don't prune the built-in ignored dirs (node_modules, vendor, target, .git, …) — walk everything")
|
|
173
204
|
.option("-v, --verbose", "verbose")
|
|
174
205
|
// Defaults: report + transitive + allLibs all ON. Use --no-* to disable.
|
|
175
206
|
.option("--no-report", "write NO output files at all — the scan, terminal summary and --fail-on gate still run (gate-only / CI mode)")
|
|
@@ -191,7 +222,7 @@ program
|
|
|
191
222
|
.option("--fail-on <level>", "exit non-zero if a production finding meets <level>: low|medium|high|critical|kev|none", "none")
|
|
192
223
|
.option("--ignore <file>", "suppress findings listed in <file> (CVE ids / coords / globs, one per line)")
|
|
193
224
|
.option("--vex <file>", "ingest a CSAF VEX: suppress CVEs marked not_affected/fixed")
|
|
194
|
-
.option("--
|
|
225
|
+
.option("--licenses", "run license detection + copyleft policy check (off by default)")
|
|
195
226
|
.option("--offline", "no network: use cached CVE/OSV/NVD/EPSS/KEV/POM data only")
|
|
196
227
|
.option("--set-nvd-key <key>", "save NVD API key to ~/.fad-checker/config.json (10× faster NVD enrichment)")
|
|
197
228
|
.option("--show-config", "print the persisted ~/.fad-checker/config.json")
|
|
@@ -207,6 +238,7 @@ program
|
|
|
207
238
|
.option("--cve-offline", "use cached CVE index only (no download)")
|
|
208
239
|
.option("--snyk", "run snyk on cleaned POMs and merge into report (requires --target)")
|
|
209
240
|
.option("--no-retire", "skip retire.js vendored-JS scan")
|
|
241
|
+
.option("--no-vendored-js-inventory", "don't list ALL identified vendored JS libs (chapter 1D) — keep only the vulnerable ones (chapter 2)")
|
|
210
242
|
.option("--retire-refresh", "ignore retire cache and re-scan")
|
|
211
243
|
.option("--transitive-depth <n>", "max transitive depth", "6")
|
|
212
244
|
.option("--ecosystem <list>", "codecs to run: auto|all|<comma list> e.g. maven,npm,nuget,composer,pypi,go,ruby (default: auto = detected)", "auto")
|
|
@@ -218,16 +250,39 @@ program
|
|
|
218
250
|
.option("--no-pypi", "skip the PyPI (Python) codec")
|
|
219
251
|
.option("--no-go", "skip the Go codec")
|
|
220
252
|
.option("--no-ruby", "skip the Ruby (Bundler) codec")
|
|
253
|
+
.option("--no-binaries", "skip scanning committed native binaries (.dll/.exe/.so/.dylib)")
|
|
221
254
|
.option("--no-jars", "skip scanning embedded .jar/.war/.ear binaries for Maven coordinates")
|
|
222
255
|
.option("--no-js", "alias: skip JS/npm/yarn manifests even if present (Maven-only)")
|
|
223
|
-
.option("--repo <url...>", "extra
|
|
224
|
-
.option("--add-repo <
|
|
225
|
-
.option("--remove-repo <
|
|
226
|
-
.option("--list-repos", "list configured
|
|
256
|
+
.option("--repo <eco=url...>", "extra registry as <ecosystem>=<url> (e.g. npm=https://npm.acme/) tried before the public one. Repeatable. Supports https://user:pass@host/.")
|
|
257
|
+
.option("--add-repo <eco>", "persist a registry: --add-repo <ecosystem> <name> <url> [--auth user:pass] [--token TOK]")
|
|
258
|
+
.option("--remove-repo <eco>", "remove a persisted registry: --remove-repo <ecosystem> <name>")
|
|
259
|
+
.option("--list-repos", "list configured registries (grouped by ecosystem) and exit")
|
|
260
|
+
.option("--auth <user:pass>", "Basic auth for --add-repo")
|
|
261
|
+
.option("--token <token>", "Bearer token for --add-repo")
|
|
227
262
|
.option("--completion <shell>", "print shell completion script (bash|zsh)");
|
|
263
|
+
// Back-compat: license detection is now OFF by default (enable with --licenses).
|
|
264
|
+
// A legacy `--no-licenses` is therefore a no-op — drop it so old invocations don't
|
|
265
|
+
// trip commander's unknown-option error.
|
|
266
|
+
if (process.argv.includes("--no-licenses")) process.argv = process.argv.filter(a => a !== "--no-licenses");
|
|
228
267
|
program.parse(process.argv);
|
|
229
268
|
|
|
230
269
|
const options = program.opts();
|
|
270
|
+
// Layered config: CLI flags > config file (--config / ./.fad-env.json, JSON) >
|
|
271
|
+
// FAD_CHECKER_ENV (a CLI-flag string) > global ~/.fad-checker/config.json >
|
|
272
|
+
// commander defaults. A file/env value fills an option only if the CLI didn't
|
|
273
|
+
// set it. `registries` are unioned separately (below). Source has src/source aliases.
|
|
274
|
+
const { loadLayers, applyLayers } = require("./lib/options-env");
|
|
275
|
+
let _layers = { fileLayer: {}, envLayer: {}, envRepos: [] };
|
|
276
|
+
try {
|
|
277
|
+
_layers = loadLayers({ cwd: process.cwd(), configPath: options.config, envStr: process.env.FAD_CHECKER_ENV, program });
|
|
278
|
+
} catch (err) {
|
|
279
|
+
console.error(chalk.red(`❌ ${err.message}`));
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
Object.assign(options, applyLayers(program, _layers, require("./lib/config").load()));
|
|
283
|
+
// --source CLI alias → src (applyLayers already maps the file/env JSON 'source' key).
|
|
284
|
+
if (!options.src && options.source) options.src = options.source;
|
|
285
|
+
|
|
231
286
|
const deps2Exclude = options.exclude ? new RegExp(options.exclude) : null;
|
|
232
287
|
const verbose = !!options.verbose;
|
|
233
288
|
|
|
@@ -357,18 +412,54 @@ async function timedPhase(label, fn) {
|
|
|
357
412
|
}
|
|
358
413
|
|
|
359
414
|
(async function main() {
|
|
360
|
-
ui.banner();
|
|
415
|
+
ui.banner(pkg.version);
|
|
361
416
|
|
|
362
417
|
// Build the Maven repo list once: persisted repos (from ~/.fad-checker/config.json)
|
|
363
418
|
// + ad-hoc --repo URLs + Maven Central as final fallback. Used by transitive
|
|
364
419
|
// resolution, outdated-version check, and existence check.
|
|
365
|
-
const {
|
|
420
|
+
const { getRegistryMap } = require("./lib/config");
|
|
366
421
|
const { buildRepoList } = require("./lib/maven-repo");
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
const
|
|
422
|
+
const { buildRegistryList } = require("./lib/registries");
|
|
423
|
+
// One-off --repo eco=url (from the CLI and the env layer), grouped by ecosystem.
|
|
424
|
+
const cliRepoMap = {};
|
|
425
|
+
for (const spec of [...(options.repo || []), ...(_layers.envRepos || [])]) {
|
|
426
|
+
const m = /^([a-z]+)=(.+)$/i.exec(String(spec));
|
|
427
|
+
if (!m) { console.error(chalk.red(`❌ --repo expects <ecosystem>=<url>, got "${spec}"`)); process.exit(1); }
|
|
428
|
+
(cliRepoMap[m[1]] ||= []).push({ url: m[2] });
|
|
429
|
+
}
|
|
430
|
+
// Union the registry sources: global config + config-file JSON + CLI/env one-offs.
|
|
431
|
+
const fileRegMap = (_layers.fileLayer && _layers.fileLayer.registries) || {};
|
|
432
|
+
const globalRegMap = getRegistryMap();
|
|
433
|
+
const regMap = {};
|
|
434
|
+
for (const eco of new Set([...Object.keys(globalRegMap), ...Object.keys(fileRegMap), ...Object.keys(cliRepoMap)])) {
|
|
435
|
+
regMap[eco] = buildRegistryList(eco, [globalRegMap[eco], fileRegMap[eco], cliRepoMap[eco]]);
|
|
436
|
+
}
|
|
437
|
+
const registriesFor = eco => regMap[eco] || [];
|
|
438
|
+
const mavenRepos = buildRepoList(regMap.maven || [], []); // appends Maven Central last
|
|
439
|
+
|
|
440
|
+
// Walk-pruning: union --exclude-path globs across every config layer (CLI + file
|
|
441
|
+
// + env + global), like registries. `defaultExcludes` (a scalar, false via
|
|
442
|
+
// --no-default-excludes) already flowed through applyLayers.
|
|
443
|
+
const excludePath = [...new Set([
|
|
444
|
+
...(options.excludePath || []),
|
|
445
|
+
...((_layers.fileLayer && _layers.fileLayer.excludePath) || []),
|
|
446
|
+
...((_layers.envLayer && _layers.envLayer.excludePath) || []),
|
|
447
|
+
...(require("./lib/config").get("excludePath") || []),
|
|
448
|
+
].filter(Boolean))];
|
|
449
|
+
const defaultExcludes = options.defaultExcludes !== false;
|
|
450
|
+
const walkOpts = { excludePath, defaultExcludes };
|
|
451
|
+
// PASSI phase 1 (--export-anonymized) is a purely local operation — parse the tree,
|
|
452
|
+
// emit public coordinates, exit. Force offline so no source ever touches the network.
|
|
453
|
+
if (options.exportAnonymized) options.offline = true;
|
|
454
|
+
const runMode = options.exportAnonymized ? "offline (PASSI phase 1 · export descriptor)"
|
|
455
|
+
: options.importAnonymized ? "import descriptor"
|
|
456
|
+
: (options.offline ? "offline" : "online");
|
|
370
457
|
if (options.src) ui.kv("source", chalk.white(options.src));
|
|
371
458
|
if (mavenRepos.length > 1) ui.kv("repos", chalk.white(mavenRepos.map(r => r.name).join(chalk.dim(" → "))));
|
|
459
|
+
const otherRegs = Object.keys(regMap).filter(e => e !== "maven" && regMap[e].length);
|
|
460
|
+
if (otherRegs.length) ui.kv("registries", chalk.white(otherRegs.map(e => `${e}:${regMap[e].length}`).join(" ")));
|
|
461
|
+
if (excludePath.length) ui.kv("exclude-path", chalk.white(excludePath.join(chalk.dim(", "))));
|
|
462
|
+
if (!defaultExcludes) ui.kv("default-excludes", chalk.yellow("off (walking node_modules/vendor/.git/…)"));
|
|
372
463
|
ui.kv("mode", chalk.white(runMode));
|
|
373
464
|
|
|
374
465
|
let wrotePom = 0;
|
|
@@ -394,7 +485,7 @@ async function timedPhase(label, fn) {
|
|
|
394
485
|
const { warmRetireSignatures } = require("./lib/retire");
|
|
395
486
|
await warmRetireSignatures({ verbose });
|
|
396
487
|
}
|
|
397
|
-
await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, collectWarnings: [] });
|
|
488
|
+
await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, regMap, collectWarnings: [] });
|
|
398
489
|
return;
|
|
399
490
|
}
|
|
400
491
|
|
|
@@ -403,9 +494,15 @@ async function timedPhase(label, fn) {
|
|
|
403
494
|
const { resolveActiveCodecs } = require("./lib/codecs/select");
|
|
404
495
|
const eco = (options.ecosystem || "auto").toLowerCase();
|
|
405
496
|
const detected = (eco === "auto")
|
|
406
|
-
? (await timedPhase("detecting ecosystems", () => detectCodecs(options.src))).map(c => c.id)
|
|
497
|
+
? (await timedPhase("detecting ecosystems", () => detectCodecs(options.src, walkOpts))).map(c => c.id)
|
|
407
498
|
: allCodecs().map(c => c.id);
|
|
499
|
+
// The binary scanner is a cross-cutting catch-all (committed native libs in ANY
|
|
500
|
+
// project), and detectCodecs' manifest-glob matcher misses versioned sonames
|
|
501
|
+
// (libz.so.1). Always make it a candidate in auto mode; --no-binaries removes it.
|
|
502
|
+
if (eco === "auto" && !detected.includes("binary")) detected.push("binary");
|
|
408
503
|
const noCodecs = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby"].filter(id => options[id] === false);
|
|
504
|
+
// `--no-binaries` maps to options.binaries (plural) but the codec id is `binary`.
|
|
505
|
+
if (options.binaries === false) noCodecs.push("binary");
|
|
409
506
|
const activeIds = resolveActiveCodecs(eco, detected, { noCodecs, noJs: !options.js });
|
|
410
507
|
const runMaven = activeIds.includes("maven");
|
|
411
508
|
const runNpm = activeIds.includes("npm") || activeIds.includes("yarn");
|
|
@@ -422,7 +519,7 @@ async function timedPhase(label, fn) {
|
|
|
422
519
|
const codec = getCodec(id);
|
|
423
520
|
let res;
|
|
424
521
|
try {
|
|
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() }));
|
|
522
|
+
res = await timedPhase(`collecting ${codec.label || id}`, () => codec.collect(options.src, { ignoreTest: !!options.ignoreTest, deps2Exclude, verbose, scanJars: options.jars !== false, srcRoot: options.src, excludePath, defaultExcludes, onJarProgress: makeJarProgress(), onBinaryProgress: null }));
|
|
426
523
|
} catch (err) {
|
|
427
524
|
console.warn(chalk.red(`❌ ${id} collect failed:`), chalk.dim(err.message));
|
|
428
525
|
continue;
|
|
@@ -435,8 +532,10 @@ async function timedPhase(label, fn) {
|
|
|
435
532
|
// --- Collection summary ---
|
|
436
533
|
const ecoCount = {};
|
|
437
534
|
let embeddedCount = 0;
|
|
535
|
+
let binaryCount = 0;
|
|
438
536
|
for (const d of resolved.values()) {
|
|
439
537
|
if (d.provenance === "embedded") { embeddedCount++; continue; } // counted separately below
|
|
538
|
+
if (d.provenance === "binary") { binaryCount++; continue; } // committed native libs, no manifest
|
|
440
539
|
ecoCount[d.ecosystem] = (ecoCount[d.ecosystem] || 0) + 1;
|
|
441
540
|
}
|
|
442
541
|
if (runMaven) ui.ok(`${chalk.bold("Maven".padEnd(8))} ${mavenCtx ? mavenCtx.pomFiles.length + " module(s) · " : ""}${ecoCount.maven || 0} direct dep(s)`);
|
|
@@ -446,6 +545,7 @@ async function timedPhase(label, fn) {
|
|
|
446
545
|
ui.ok(`${chalk.bold(((getCodec(id)?.label) || id).padEnd(8))} ${n} dep(s)`);
|
|
447
546
|
}
|
|
448
547
|
if (embeddedCount) ui.ok(`${chalk.bold("Embedded".padEnd(8))} ${embeddedCount} coord(s) in .jar/.war/.ear`);
|
|
548
|
+
if (binaryCount) ui.ok(`${chalk.bold("Binary".padEnd(8))} ${binaryCount} native lib(s) (.dll/.exe/.so/.dylib)`);
|
|
449
549
|
if (!ecoCount.maven && !ecoCount.npm && !Object.keys(ecoCount).length) ui.warn("no dependencies found in the source tree");
|
|
450
550
|
if (collectWarnings.length) {
|
|
451
551
|
ui.warn(`${collectWarnings.length} manifest warning(s) — best-effort / no lockfile:`);
|
|
@@ -465,6 +565,17 @@ async function timedPhase(label, fn) {
|
|
|
465
565
|
ui.ok(`${chalk.bold(descriptor.summary.total)} dep(s) (${ecoSummary || "none"}) → ${chalk.white(options.exportAnonymized)}`);
|
|
466
566
|
ui.info(chalk.dim("public coordinates only — no paths/URLs/host info. Review before transfer."));
|
|
467
567
|
if (!descriptor.summary.total) ui.warn("no dependencies collected — descriptor is empty");
|
|
568
|
+
// Guide the operator through the remaining two phases (the descriptor file +
|
|
569
|
+
// this run's --src are filled in so the commands are copy-paste ready).
|
|
570
|
+
const descFile = options.exportAnonymized;
|
|
571
|
+
const srcShown = options.src || "./proj";
|
|
572
|
+
ui.section("Next steps");
|
|
573
|
+
ui.info(`${chalk.dim("Phase 2 —")} ${chalk.cyan("ONLINE")} ${chalk.dim("(any box, no --src): warm the caches, then bundle them")}`);
|
|
574
|
+
ui.info(" " + chalk.white(`fad-checker --import-anonymized ${descFile}`));
|
|
575
|
+
ui.info(" " + chalk.white("fad-checker --export-cache fad-cache.tar.gz"));
|
|
576
|
+
ui.info(`${chalk.dim("Phase 3 —")} ${chalk.cyan("OFFLINE")} ${chalk.dim("(back here): import the cache, then run the full report")}`);
|
|
577
|
+
ui.info(" " + chalk.white("fad-checker --import-cache fad-cache.tar.gz"));
|
|
578
|
+
ui.info(" " + chalk.white(`fad-checker -s ${srcShown} --offline`));
|
|
468
579
|
return;
|
|
469
580
|
}
|
|
470
581
|
|
|
@@ -567,7 +678,7 @@ async function timedPhase(label, fn) {
|
|
|
567
678
|
// The scan always runs — it feeds the terminal summary, the file outputs and the
|
|
568
679
|
// CI gate. Which files get written is decided by the --report-* family inside
|
|
569
680
|
// (HTML + .doc by default; --no-report writes nothing).
|
|
570
|
-
await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, collectWarnings });
|
|
681
|
+
await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings });
|
|
571
682
|
if (!readOnly) {
|
|
572
683
|
ui.section("Next step");
|
|
573
684
|
ui.info(`run Snyk on the cleaned tree:`);
|
|
@@ -576,7 +687,8 @@ async function timedPhase(label, fn) {
|
|
|
576
687
|
})();
|
|
577
688
|
|
|
578
689
|
async function runReportFlow(resolved, ecoFlags = {}) {
|
|
579
|
-
const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], collectWarnings = [] } = ecoFlags;
|
|
690
|
+
const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [] } = ecoFlags;
|
|
691
|
+
const registriesFor = eco => regMap[eco] || [];
|
|
580
692
|
const { expandWithTransitives } = require("./lib/cve-match");
|
|
581
693
|
const { writeReports, computeStats } = require("./lib/cve-report");
|
|
582
694
|
const { getCodec } = require("./lib/codecs");
|
|
@@ -621,9 +733,11 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
621
733
|
const willKev = !!options.kev;
|
|
622
734
|
const willLicenses = !!options.licenses;
|
|
623
735
|
const willRetire = !!options.retire;
|
|
736
|
+
// Identify committed native binaries by checksum (deps.dev + CIRCL) when present.
|
|
737
|
+
const willBinaryId = [...resolved.values()].some(d => d.provenance === "binary");
|
|
624
738
|
// License detection piggybacks on the registry passes (same fetched metadata),
|
|
625
739
|
// so it adds no progress step of its own.
|
|
626
|
-
const totalSteps = [willTransitive, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire].filter(Boolean).length;
|
|
740
|
+
const totalSteps = [willTransitive, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
|
|
627
741
|
const progress = new ui.Progress(totalSteps);
|
|
628
742
|
|
|
629
743
|
if (willTransitive) {
|
|
@@ -653,6 +767,17 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
653
767
|
}
|
|
654
768
|
}
|
|
655
769
|
|
|
770
|
+
// 1c. Identify committed native binaries by checksum (deps.dev → CIRCL).
|
|
771
|
+
if (willBinaryId) {
|
|
772
|
+
const st = progress.start("Binary identification (deps.dev + CIRCL)");
|
|
773
|
+
try {
|
|
774
|
+
const { enrichUnmanaged } = require("./lib/unmanaged");
|
|
775
|
+
const s = await enrichUnmanaged(resolved, { offline, onProgress: (p, t) => st.tick(p, t) });
|
|
776
|
+
const bits = [`${s.identified}/${s.total} identified`, s.pristine ? `${s.pristine} pristine` : null, s.unknown ? `${s.unknown} unknown` : null, s.malicious ? `${s.malicious} ⚠ malicious` : null].filter(Boolean).join(", ");
|
|
777
|
+
st.done(bits);
|
|
778
|
+
} catch (err) { st.fail(err.message); }
|
|
779
|
+
}
|
|
780
|
+
|
|
656
781
|
// 2. EOL frameworks (endoflife.date) — always a step.
|
|
657
782
|
let eolResults = [];
|
|
658
783
|
{
|
|
@@ -686,7 +811,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
686
811
|
const st = progress.start("npm registry");
|
|
687
812
|
try {
|
|
688
813
|
const { checkNpmRegistryDeps } = require("./lib/codecs/npm/registry");
|
|
689
|
-
const npmReg = await checkNpmRegistryDeps(resolved, { verbose, offline, allLibs: options.allLibs, onProgress: (p, t) => st.tick(p, t) });
|
|
814
|
+
const npmReg = await checkNpmRegistryDeps(resolved, { verbose, offline, allLibs: options.allLibs, registries: registriesFor("npm"), onProgress: (p, t) => st.tick(p, t) });
|
|
690
815
|
obsoleteResults = obsoleteResults.concat(npmReg.deprecated);
|
|
691
816
|
outdatedResults = outdatedResults.concat(npmReg.outdated);
|
|
692
817
|
licenseFindings = licenseFindings.concat(npmReg.licensed || []);
|
|
@@ -699,7 +824,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
699
824
|
const codec = getCodec(id);
|
|
700
825
|
const st = progress.start(`${codec.label || id} registry`);
|
|
701
826
|
try {
|
|
702
|
-
const reg = await codec.checkRegistry(resolved, { verbose, offline, allLibs: options.allLibs, onProgress: (p, t) => st.tick(p, t) });
|
|
827
|
+
const reg = await codec.checkRegistry(resolved, { verbose, offline, allLibs: options.allLibs, registries: registriesFor(id), onProgress: (p, t) => st.tick(p, t) });
|
|
703
828
|
obsoleteResults = obsoleteResults.concat(reg.deprecated || []);
|
|
704
829
|
outdatedResults = outdatedResults.concat(reg.outdated || []);
|
|
705
830
|
licenseFindings = licenseFindings.concat(reg.licensed || []);
|
|
@@ -793,6 +918,8 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
793
918
|
// vendored .js (which can live in a Maven project's resources too). The
|
|
794
919
|
// scanner is owned by the npm codec but runs whenever --retire is on.
|
|
795
920
|
let retireMatches = [];
|
|
921
|
+
let vendoredJsInventory = [];
|
|
922
|
+
let retireWarnings = [];
|
|
796
923
|
if (willRetire) {
|
|
797
924
|
const st = progress.start("retire.js (vendored JS)");
|
|
798
925
|
const sc = (getCodec("npm").nativeScanners || []).find(s => s.kind === "vendored");
|
|
@@ -802,8 +929,17 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
802
929
|
try {
|
|
803
930
|
const r = await sc.scan(resolved, { src: options.src, verbose, retireRefresh: !!options.retireRefresh, offline });
|
|
804
931
|
retireMatches = r.matches || [];
|
|
805
|
-
|
|
806
|
-
|
|
932
|
+
if (options.vendoredJsInventory !== false) vendoredJsInventory = r.meta?.inventory || [];
|
|
933
|
+
const invN = vendoredJsInventory.length;
|
|
934
|
+
if (r.meta?.error) {
|
|
935
|
+
// A genuine scan failure — surface it instead of letting an empty
|
|
936
|
+
// vendored-JS chapter look like "nothing found".
|
|
937
|
+
st.fail(r.meta.error);
|
|
938
|
+
retireWarnings.push({ type: "retire-failed", message: `${r.meta.error} — the vendored-JS scan (chapters 1D / 2) could not run, so any vendored \`.js\` (jQuery, Bootstrap, …) is NOT covered. Re-run with \`-v\` for the exact error; check the \`--src\` path exists and is readable.` });
|
|
939
|
+
} else {
|
|
940
|
+
st.done(`${retireMatches.length} finding(s)${invN ? ` · ${invN} lib(s) inventoried` : ""}`);
|
|
941
|
+
}
|
|
942
|
+
} catch (err) { st.fail(err.message); retireWarnings.push({ type: "retire-failed", message: `retire.js scan failed: ${err.message} — vendored-JS chapters (1D / 2) not covered.` }); }
|
|
807
943
|
}
|
|
808
944
|
}
|
|
809
945
|
|
|
@@ -865,6 +1001,15 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
865
1001
|
const sev = ui.sevColor;
|
|
866
1002
|
const depLabel = d => d.ecosystem === "npm" ? `npm:${d.artifactId}` : `${d.groupId}:${d.artifactId}`;
|
|
867
1003
|
const coordOf = depLabel; // npm deps show as "npm:name", others as "g:a"
|
|
1004
|
+
// Where a finding's dependency was declared — the pom.xml / package.json / jar
|
|
1005
|
+
// (embedded jars carry a "app.jar!/BOOT-INF/lib/…" manifestPath). Shown so EOL /
|
|
1006
|
+
// obsolete / outdated entries point at the file the reader has to edit.
|
|
1007
|
+
const definedInOf = d => {
|
|
1008
|
+
const paths = (d?.manifestPaths?.length ? d.manifestPaths : d?.pomPaths?.length ? d.pomPaths : []);
|
|
1009
|
+
if (!paths.length) return "";
|
|
1010
|
+
const rel = paths.map(p => { try { return path.relative(options.src, p); } catch { return p; } });
|
|
1011
|
+
return chalk.dim(` ← ${rel[0]}${rel.length > 1 ? ` (+${rel.length - 1})` : ""}`);
|
|
1012
|
+
};
|
|
868
1013
|
const fmtStats = s => [
|
|
869
1014
|
s.critical ? sev("CRITICAL")(`${s.critical} critical`) : null,
|
|
870
1015
|
s.high ? sev("HIGH")(`${s.high} high`) : null,
|
|
@@ -896,16 +1041,30 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
896
1041
|
if (embeddedActive.length > 8) console.log(chalk.dim(` …and ${embeddedActive.length - 8} more (see report ch.1B)`));
|
|
897
1042
|
}
|
|
898
1043
|
|
|
1044
|
+
{
|
|
1045
|
+
const { buildInventory } = require("./lib/unmanaged");
|
|
1046
|
+
const inv = buildInventory(resolved);
|
|
1047
|
+
if (inv.length) {
|
|
1048
|
+
heading("Unmanaged binaries", inv.length);
|
|
1049
|
+
for (const e of inv.slice(0, 10)) {
|
|
1050
|
+
const id = e.identity ? `${e.identity.ecosystem ? e.identity.ecosystem + ":" : ""}${e.identity.name || ""}${e.identity.version ? "@" + e.identity.version : ""}` : chalk.dim("unknown");
|
|
1051
|
+
const flags = [e.knownMalicious ? chalk.bgRed.white(" malicious ") : null, e.nameMismatch ? chalk.yellow("name≠checksum") : null, e.shouldBeManaged ? chalk.cyan("should-be-managed") : null, (e.noOnlineInfo ? chalk.dim("unknown") : null)].filter(Boolean).join(" ");
|
|
1052
|
+
console.log(" " + chalk.white(path.basename(String(e.path))) + " " + chalk.dim(id) + (flags ? " " + flags : ""));
|
|
1053
|
+
}
|
|
1054
|
+
if (inv.length > 10) console.log(chalk.dim(` …and ${inv.length - 10} more (see report ch.1C)`));
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
899
1058
|
heading("EOL frameworks", eolResults.length);
|
|
900
|
-
for (const e of eolResults.slice(0, 8)) console.log(" " + chalk.yellow(e.product.padEnd(18)) + " " + chalk.dim(`${coordOf(e.dep)}:${e.dep.version}`) + " " + chalk.dim(e.eol === true ? "EOL" : String(e.eol)));
|
|
1059
|
+
for (const e of eolResults.slice(0, 8)) console.log(" " + chalk.yellow(e.product.padEnd(18)) + " " + chalk.dim(`${coordOf(e.dep)}:${e.dep.version}`) + " " + chalk.dim(e.eol === true ? "EOL" : String(e.eol)) + definedInOf(e.dep));
|
|
901
1060
|
if (eolResults.length > 8) console.log(chalk.dim(` …and ${eolResults.length - 8} more`));
|
|
902
1061
|
|
|
903
1062
|
heading("Obsolete / deprecated", obsoleteResults.length);
|
|
904
|
-
for (const o of obsoleteResults.slice(0, 8)) console.log(" " + chalk.dim(`${coordOf(o.dep)}:${o.dep.version}`) + " → " + (o.replacement || chalk.dim("n/a")));
|
|
1063
|
+
for (const o of obsoleteResults.slice(0, 8)) console.log(" " + chalk.dim(`${coordOf(o.dep)}:${o.dep.version}`) + " → " + (o.replacement || chalk.dim("n/a")) + definedInOf(o.dep));
|
|
905
1064
|
if (obsoleteResults.length > 8) console.log(chalk.dim(` …and ${obsoleteResults.length - 8} more`));
|
|
906
1065
|
|
|
907
1066
|
heading("Outdated", outdatedResults.length, options.allLibs ? "" : chalk.dim("pass -a/--allLibs to query registries"));
|
|
908
|
-
for (const o of outdatedResults.slice(0, 8)) console.log(" " + chalk.dim(coordOf(o.dep)) + ` ${o.dep.version} → ${chalk.green(o.latest)}`);
|
|
1067
|
+
for (const o of outdatedResults.slice(0, 8)) console.log(" " + chalk.dim(coordOf(o.dep)) + ` ${o.dep.version} → ${chalk.green(o.latest)}` + definedInOf(o.dep));
|
|
909
1068
|
if (outdatedResults.length > 8) console.log(chalk.dim(` …and ${outdatedResults.length - 8} more`));
|
|
910
1069
|
|
|
911
1070
|
if (retireMatches.length) {
|
|
@@ -983,6 +1142,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
983
1142
|
}] : []),
|
|
984
1143
|
...npmWarnings,
|
|
985
1144
|
...scanWarnings,
|
|
1145
|
+
...retireWarnings,
|
|
986
1146
|
...(privateLibIds.length ? [{
|
|
987
1147
|
type: "private-libs",
|
|
988
1148
|
count: privateLibIds.length,
|
|
@@ -999,7 +1159,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
999
1159
|
if (out.html || out.doc) {
|
|
1000
1160
|
await ensureDir(out.html); await ensureDir(out.doc);
|
|
1001
1161
|
const { htmlPath, docPath } = await writeReports({
|
|
1002
|
-
cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches,
|
|
1162
|
+
cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches, vendoredJsInventory,
|
|
1003
1163
|
eolResults, obsoleteResults, outdatedResults, licenseResults,
|
|
1004
1164
|
resolvedDeps: resolved, projectInfo, warnings: reportWarnings,
|
|
1005
1165
|
htmlPath: out.html, docPath: out.doc,
|
|
@@ -1030,7 +1190,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1030
1190
|
try {
|
|
1031
1191
|
const { writeFindings } = require("./lib/json-export");
|
|
1032
1192
|
await ensureDir(out.json);
|
|
1033
|
-
writeFindings({ cveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version }, out.json);
|
|
1193
|
+
writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version }, out.json);
|
|
1034
1194
|
wrote.push(["Findings JSON", out.json]);
|
|
1035
1195
|
} catch (err) { ui.warn(`JSON export failed: ${err.message}`); }
|
|
1036
1196
|
}
|
|
@@ -1112,3 +1272,5 @@ function mergeBySource(existing, additions) {
|
|
|
1112
1272
|
});
|
|
1113
1273
|
return merged;
|
|
1114
1274
|
}
|
|
1275
|
+
|
|
1276
|
+
} // end: compiled-binary retire-mode guard (see top of file)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/binary/scan.js — walk a source tree for committed NATIVE binaries
|
|
3
|
+
* (.dll/.exe/.so/.dylib) and hash each. Dependency archives (.jar/.war/.ear) are
|
|
4
|
+
* owned by the Maven codec's jar-scan.js, not here.
|
|
5
|
+
*
|
|
6
|
+
* Selection requires BOTH an allowlisted extension AND a confirming magic byte
|
|
7
|
+
* (sniff.js), so images/fonts/assets — even with a spoofed extension — are never
|
|
8
|
+
* hashed or reported.
|
|
9
|
+
*
|
|
10
|
+
* @author: N.BRAUN
|
|
11
|
+
* @email: pp9ping@gmail.com
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const crypto = require("crypto");
|
|
16
|
+
const { sniffKind, extKind } = require("./sniff");
|
|
17
|
+
|
|
18
|
+
// Same skip set the other codecs use; never a place vendored binaries we own live.
|
|
19
|
+
const SKIP = new Set([
|
|
20
|
+
".git", ".idea", ".vscode", "node_modules", "dist", "build", "out",
|
|
21
|
+
"target", "vendor", "testdata", ".svn", ".hg", ".gradle", ".cache",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const MAGIC_BYTES = 8; // enough for every signature we sniff
|
|
25
|
+
|
|
26
|
+
function hashFile(fp) {
|
|
27
|
+
const buf = fs.readFileSync(fp);
|
|
28
|
+
return {
|
|
29
|
+
size: buf.length,
|
|
30
|
+
sha1: crypto.createHash("sha1").update(buf).digest("hex"),
|
|
31
|
+
sha256: crypto.createHash("sha256").update(buf).digest("hex"),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readMagic(fp) {
|
|
36
|
+
let fd;
|
|
37
|
+
try {
|
|
38
|
+
fd = fs.openSync(fp, "r");
|
|
39
|
+
const buf = Buffer.alloc(MAGIC_BYTES);
|
|
40
|
+
const n = fs.readSync(fd, buf, 0, MAGIC_BYTES, 0);
|
|
41
|
+
return buf.subarray(0, n);
|
|
42
|
+
} catch { return Buffer.alloc(0); }
|
|
43
|
+
finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* ignore */ } }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Walk `dir`, return [{ path, kind, size, sha1, sha256, declaredName }]. */
|
|
47
|
+
function scanBinaries(dir, opts = {}) {
|
|
48
|
+
const { onProgress } = opts;
|
|
49
|
+
const { makeDirFilter } = require("../../path-filter");
|
|
50
|
+
const skipDir = makeDirFilter({ srcRoot: opts.srcRoot || dir, defaultSkip: SKIP, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
|
|
51
|
+
const out = [];
|
|
52
|
+
const stack = [dir];
|
|
53
|
+
while (stack.length) {
|
|
54
|
+
const cur = stack.pop();
|
|
55
|
+
let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const fp = path.join(cur, e.name);
|
|
58
|
+
if (e.isDirectory()) { if (!skipDir(fp, e.name)) stack.push(fp); continue; }
|
|
59
|
+
if (!e.isFile()) continue;
|
|
60
|
+
const ext = extKind(e.name);
|
|
61
|
+
if (!ext) continue; // not an allowlisted extension
|
|
62
|
+
if (sniffKind(readMagic(fp)) !== ext) continue; // magic must confirm the extension
|
|
63
|
+
if (onProgress) onProgress(fp);
|
|
64
|
+
const h = hashFile(fp);
|
|
65
|
+
out.push({ path: fp, kind: ext, size: h.size, sha1: h.sha1, sha256: h.sha256, declaredName: e.name });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { scanBinaries };
|
|
@@ -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 };
|