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.
Files changed (41) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +100 -32
  3. package/fad-checker-report/cve-report.doc +643 -105
  4. package/fad-checker-report/cve-report.html +653 -108
  5. package/fad-checker.js +265 -55
  6. package/lib/codecs/binary/scan.js +71 -0
  7. package/lib/codecs/binary/sniff.js +37 -0
  8. package/lib/codecs/binary.codec.js +54 -0
  9. package/lib/codecs/composer.codec.js +7 -3
  10. package/lib/codecs/go/registry.js +19 -11
  11. package/lib/codecs/go.codec.js +7 -3
  12. package/lib/codecs/index.js +73 -4
  13. package/lib/codecs/maven/jar-scan.js +179 -28
  14. package/lib/codecs/maven.codec.js +4 -2
  15. package/lib/codecs/npm/collect.js +3 -1
  16. package/lib/codecs/npm/parse.js +29 -1
  17. package/lib/codecs/npm/registry.js +29 -18
  18. package/lib/codecs/npm.codec.js +10 -4
  19. package/lib/codecs/nuget.codec.js +7 -3
  20. package/lib/codecs/pypi/registry.js +16 -10
  21. package/lib/codecs/pypi.codec.js +7 -3
  22. package/lib/codecs/recipes.js +9 -1
  23. package/lib/codecs/ruby/registry.js +16 -10
  24. package/lib/codecs/ruby.codec.js +7 -3
  25. package/lib/config.js +34 -23
  26. package/lib/core.js +22 -3
  27. package/lib/cve-match.js +3 -2
  28. package/lib/cve-report.js +59 -6
  29. package/lib/dep-record.js +12 -9
  30. package/lib/hash-id.js +78 -0
  31. package/lib/json-export.js +8 -1
  32. package/lib/license-policy.js +3 -1
  33. package/lib/options-env.js +113 -0
  34. package/lib/outdated.js +3 -0
  35. package/lib/parallel-walk.js +50 -0
  36. package/lib/path-filter.js +58 -0
  37. package/lib/registries.js +112 -0
  38. package/lib/retire.js +66 -0
  39. package/lib/scan-completeness.js +7 -1
  40. package/lib/unmanaged.js +82 -0
  41. 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
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"));
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")) {
@@ -58,8 +61,9 @@ if (process.argv.includes("--show-config")) {
58
61
  const cfg = config.load();
59
62
  const masked = { ...cfg };
60
63
  if (masked.nvd_api_key) masked.nvd_api_key = masked.nvd_api_key.slice(0, 8) + "…" + masked.nvd_api_key.slice(-4);
61
- if (Array.isArray(masked.maven_repos)) {
62
- masked.maven_repos = masked.maven_repos.map(r => ({ ...r, auth: r.auth ? "***" : undefined }));
64
+ if (masked.registries && typeof masked.registries === "object") {
65
+ masked.registries = Object.fromEntries(Object.entries(masked.registries).map(([eco, list]) =>
66
+ [eco, (list || []).map(r => ({ ...r, auth: r.auth ? "***" : undefined, token: r.token ? "***" : undefined }))]));
63
67
  }
64
68
  console.log(JSON.stringify(masked, null, 2));
65
69
  console.log(chalk.gray("Config file: " + config.CONFIG_PATH));
@@ -69,44 +73,58 @@ if (process.argv.includes("--show-config")) {
69
73
  // -------- --add-repo / --remove-repo / --list-repos (run before program.parse) --------
70
74
  if (process.argv.includes("--add-repo") || process.argv.includes("--remove-repo") || process.argv.includes("--list-repos")) {
71
75
  const config = require("./lib/config");
76
+ const { SUPPORTED } = require("./lib/registries");
77
+ const ecoErr = eco => {
78
+ if (!SUPPORTED.includes(eco)) {
79
+ console.error(chalk.red(`❌ unknown ecosystem "${eco}". Supported: ${SUPPORTED.join(", ")}`));
80
+ process.exit(1);
81
+ }
82
+ };
72
83
  if (process.argv.includes("--list-repos")) {
73
- const repos = config.getMavenRepos();
74
- if (!repos.length) {
75
- console.log(chalk.gray("No custom Maven repos configured (Maven Central is always used as fallback)."));
84
+ const map = config.getRegistryMap();
85
+ const ecos = Object.keys(map).filter(e => (map[e] || []).length);
86
+ if (!ecos.length) {
87
+ console.log(chalk.gray("No custom registries configured (public registries are always the fallback)."));
76
88
  } else {
77
- console.log(chalk.bold("Configured Maven repos (tried in order, then Central):"));
78
- for (const r of repos) {
79
- const authMark = r.auth ? chalk.yellow(" [auth]") : "";
80
- console.log(` • ${chalk.cyan(r.name)} ${r.url}${authMark}`);
89
+ for (const eco of ecos) {
90
+ console.log(chalk.bold(`${eco} (tried in order, then public):`));
91
+ for (const r of map[eco]) {
92
+ const authMark = (r.auth || r.token) ? chalk.yellow(" [auth]") : "";
93
+ console.log(` • ${chalk.cyan(r.name)} → ${r.url}${authMark}`);
94
+ }
81
95
  }
82
96
  }
83
97
  process.exit(0);
84
98
  }
85
99
  if (process.argv.includes("--add-repo")) {
86
100
  const idx = process.argv.indexOf("--add-repo");
87
- const name = process.argv[idx + 1];
88
- const url = process.argv[idx + 2];
89
- if (!name || name.startsWith("-") || !url || url.startsWith("-")) {
90
- console.error(chalk.red("--add-repo requires <name> <url>"));
91
- console.error(" Example: fad-checker --add-repo nexus https://nexus.acme.com/repository/maven-public/");
92
- console.error(" Optional auth: --add-repo nexus https://nexus.acme.com/repository/maven-public/ --auth user:pass");
101
+ const [eco, name, url] = [process.argv[idx + 1], process.argv[idx + 2], process.argv[idx + 3]];
102
+ if (!eco || !name || !url || [eco, name, url].some(a => a.startsWith("-"))) {
103
+ console.error(chalk.red("❌ --add-repo requires <ecosystem> <name> <url>"));
104
+ console.error(" Example: fad-checker --add-repo npm verdaccio https://npm.acme/ --token TOK");
105
+ console.error(" Maven: fad-checker --add-repo maven nexus https://nexus.acme/maven-public/ --auth user:pass");
93
106
  process.exit(1);
94
107
  }
108
+ ecoErr(eco);
95
109
  const authIdx = process.argv.indexOf("--auth");
96
- const auth = authIdx > -1 ? process.argv[authIdx + 1] : null;
97
- config.addMavenRepo(name, url, auth);
98
- console.log(chalk.green(`✅ Added Maven repo "${name}" → ${url}${auth ? " (with auth)" : ""}`));
110
+ const tokIdx = process.argv.indexOf("--token");
111
+ config.addRegistry(eco, name, url, {
112
+ auth: authIdx > -1 ? process.argv[authIdx + 1] : null,
113
+ token: tokIdx > -1 ? process.argv[tokIdx + 1] : null,
114
+ });
115
+ console.log(chalk.green(`✅ Added ${eco} registry "${name}" → ${url}`));
99
116
  process.exit(0);
100
117
  }
101
118
  if (process.argv.includes("--remove-repo")) {
102
119
  const idx = process.argv.indexOf("--remove-repo");
103
- const name = process.argv[idx + 1];
104
- if (!name || name.startsWith("-")) {
105
- console.error(chalk.red("❌ --remove-repo requires <name>"));
120
+ const [eco, name] = [process.argv[idx + 1], process.argv[idx + 2]];
121
+ if (!eco || !name || [eco, name].some(a => a.startsWith("-"))) {
122
+ console.error(chalk.red("❌ --remove-repo requires <ecosystem> <name>"));
106
123
  process.exit(1);
107
124
  }
108
- const removed = config.removeMavenRepo(name);
109
- console.log(removed ? chalk.green(`✅ Removed Maven repo "${name}"`) : chalk.yellow(`⚠️ No Maven repo named "${name}"`));
125
+ ecoErr(eco);
126
+ const removed = config.removeRegistry(eco, name);
127
+ console.log(removed ? chalk.green(`✅ Removed ${eco} registry "${name}"`) : chalk.yellow(`⚠️ No ${eco} registry named "${name}"`));
110
128
  process.exit(removed ? 0 : 1);
111
129
  }
112
130
  }
@@ -166,7 +184,11 @@ program
166
184
  .option("-t, --target <target>", "output directory (will be rm before written). If omitted, the run is read-only.")
167
185
  // Not a requiredOption: --import-anonymized scans a descriptor with no source tree.
168
186
  .option("-s, --src <src>", "root directory containing pom.xml files")
169
- .option("-e, --exclude <exclude>", "regex of groupId to exclude, e.g. '^(client|private)\\.'")
187
+ .option("--source <src>", "alias of --src (also the JSON config key 'source')")
188
+ .option("--config <file>", "load default options from a JSON config file (else ./.fad-env.json)")
189
+ .option("-e, --exclude <exclude>", "regex of groupId/name to exclude, e.g. '^(client|private)\\.'")
190
+ .option("--exclude-path <glob...>", "ignore sub-paths during the walk (gitignore-style glob, relative to --src). Repeatable. e.g. 'packages/legacy/**' '**/fixtures/**'")
191
+ .option("--no-default-excludes", "don't prune the built-in ignored dirs (node_modules, vendor, target, .git, …) — walk everything")
170
192
  .option("-v, --verbose", "verbose")
171
193
  // Defaults: report + transitive + allLibs all ON. Use --no-* to disable.
172
194
  .option("--no-report", "write NO output files at all — the scan, terminal summary and --fail-on gate still run (gate-only / CI mode)")
@@ -204,6 +226,7 @@ program
204
226
  .option("--cve-offline", "use cached CVE index only (no download)")
205
227
  .option("--snyk", "run snyk on cleaned POMs and merge into report (requires --target)")
206
228
  .option("--no-retire", "skip retire.js vendored-JS scan")
229
+ .option("--no-vendored-js-inventory", "don't list ALL identified vendored JS libs (chapter 1D) — keep only the vulnerable ones (chapter 2)")
207
230
  .option("--retire-refresh", "ignore retire cache and re-scan")
208
231
  .option("--transitive-depth <n>", "max transitive depth", "6")
209
232
  .option("--ecosystem <list>", "codecs to run: auto|all|<comma list> e.g. maven,npm,nuget,composer,pypi,go,ruby (default: auto = detected)", "auto")
@@ -215,16 +238,35 @@ program
215
238
  .option("--no-pypi", "skip the PyPI (Python) codec")
216
239
  .option("--no-go", "skip the Go codec")
217
240
  .option("--no-ruby", "skip the Ruby (Bundler) codec")
241
+ .option("--no-binaries", "skip scanning committed native binaries (.dll/.exe/.so/.dylib)")
218
242
  .option("--no-jars", "skip scanning embedded .jar/.war/.ear binaries for Maven coordinates")
219
243
  .option("--no-js", "alias: skip JS/npm/yarn manifests even if present (Maven-only)")
220
- .option("--repo <url...>", "extra Maven repository URL(s) to try before Maven Central. Supports https://user:pass@host/path/. Repeatable.")
221
- .option("--add-repo <name>", "persist a Maven repo: --add-repo <name> <url> [--auth user:pass]")
222
- .option("--remove-repo <name>", "remove a persisted Maven repo by name")
223
- .option("--list-repos", "list configured Maven repos and exit")
244
+ .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/.")
245
+ .option("--add-repo <eco>", "persist a registry: --add-repo <ecosystem> <name> <url> [--auth user:pass] [--token TOK]")
246
+ .option("--remove-repo <eco>", "remove a persisted registry: --remove-repo <ecosystem> <name>")
247
+ .option("--list-repos", "list configured registries (grouped by ecosystem) and exit")
248
+ .option("--auth <user:pass>", "Basic auth for --add-repo")
249
+ .option("--token <token>", "Bearer token for --add-repo")
224
250
  .option("--completion <shell>", "print shell completion script (bash|zsh)");
225
251
  program.parse(process.argv);
226
252
 
227
253
  const options = program.opts();
254
+ // Layered config: CLI flags > config file (--config / ./.fad-env.json, JSON) >
255
+ // FAD_CHECKER_ENV (a CLI-flag string) > global ~/.fad-checker/config.json >
256
+ // commander defaults. A file/env value fills an option only if the CLI didn't
257
+ // set it. `registries` are unioned separately (below). Source has src/source aliases.
258
+ const { loadLayers, applyLayers } = require("./lib/options-env");
259
+ let _layers = { fileLayer: {}, envLayer: {}, envRepos: [] };
260
+ try {
261
+ _layers = loadLayers({ cwd: process.cwd(), configPath: options.config, envStr: process.env.FAD_CHECKER_ENV, program });
262
+ } catch (err) {
263
+ console.error(chalk.red(`❌ ${err.message}`));
264
+ process.exit(1);
265
+ }
266
+ Object.assign(options, applyLayers(program, _layers, require("./lib/config").load()));
267
+ // --source CLI alias → src (applyLayers already maps the file/env JSON 'source' key).
268
+ if (!options.src && options.source) options.src = options.source;
269
+
228
270
  const deps2Exclude = options.exclude ? new RegExp(options.exclude) : null;
229
271
  const verbose = !!options.verbose;
230
272
 
@@ -269,20 +311,87 @@ if (options.src && options.target) {
269
311
  }
270
312
  }
271
313
 
272
- async function checkMavenLibExist(groupId, artifactId, repos) {
314
+ // Maven Central presence cache (~/.fad-checker/maven-exists-cache.json) — keyed by
315
+ // "g:a", value true (on a repo) / false (absent → likely private). Persisted so an
316
+ // online warm-up populates it, --export-cache ships it, and an --offline air-gapped
317
+ // run reads it instead of probing the network. Returns:
318
+ // true → present on a configured repo
319
+ // false → absent (likely private)
320
+ // null → unknown (offline + not cached, or probe error) — caller must NOT guess
321
+ const MAVEN_EXISTS_CACHE_PATH = require("path").join(require("./lib/outdated").CACHE_DIR, "maven-exists-cache.json");
322
+ const MAVEN_EXISTS_MAX_AGE_MS = 7 * 24 * 3600 * 1000; // 7 days
323
+
324
+ async function checkMavenLibExist(groupId, artifactId, repos, cache, opts = {}) {
273
325
  const g = core.coord(groupId);
274
326
  const a = core.coord(artifactId);
275
- if (!g || !a) return false;
327
+ if (!g || !a) return null;
328
+ const key = `${g}:${a}`;
329
+ if (cache && Object.prototype.hasOwnProperty.call(cache.entries, key)) return cache.entries[key];
330
+ if (opts.offline) return null; // air-gapped + not warmed: honestly unknown, never network
276
331
  const p = `${g.replace(/\./g, "/")}/${a}/maven-metadata.xml`;
277
332
  const { existsInAny } = require("./lib/maven-repo");
278
333
  try {
279
334
  const hit = await existsInAny(repos, p, { userAgent: "fad-checker-existence" });
280
- if (hit) return true;
281
- if (verbose) console.log(chalk.dim(` not on any repo: ${g}:${a}`));
282
- return false;
335
+ if (cache) cache.entries[key] = !!hit;
336
+ if (!hit && verbose) console.log(chalk.dim(` not on any repo: ${g}:${a}`));
337
+ return !!hit;
283
338
  } catch (err) {
284
339
  if (verbose) console.info(chalk.dim(` error querying repos: ${g}:${a} — ${err.message}`));
285
- return false;
340
+ return null; // probe failed → unknown, don't poison the cache or mislabel as private
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Build an onProgress callback for the embedded-JAR scan so the user sees what's
346
+ * happening — the scan reads + unzips archives synchronously and can block for a
347
+ * while on big or numerous fat-jars (incl. the silent recursion through a fat-jar's
348
+ * bundled libs). The scanner reports EVERY archive (top-level + nested):
349
+ * - On a TTY: one transient line, rewritten in place, naming the archive being
350
+ * read right now (so a long pause clearly points at the culprit). Cleared at end.
351
+ * - Off a TTY (CI/pipe): a throttled line every ~250 archives so logs show forward
352
+ * motion without being spammed, plus a start and a final summary line.
353
+ * Returns a fresh closure per codec.
354
+ */
355
+ function makeJarProgress() {
356
+ let total = 0, lastLogged = 0;
357
+ const STEP = 250;
358
+ return (ev) => {
359
+ if (!ev) return;
360
+ if (ev.phase === "start") {
361
+ total = ev.total || 0;
362
+ if (total && !ui.isTTY) ui.info(chalk.dim(`scanning ${total} embedded archive(s) (.jar/.war/.ear)…`));
363
+ } else if (ev.phase === "scan" && total) {
364
+ if (ui.isTTY) {
365
+ const head = total ? `${ev.scanned}/${total}+` : String(ev.scanned);
366
+ process.stdout.write(`\r ${chalk.dim("·")} ${chalk.dim(`reading embedded JARs (${head})`)} ${chalk.dim(ev.path)}\x1b[K`);
367
+ } else if (ev.scanned - lastLogged >= STEP) {
368
+ lastLogged = ev.scanned;
369
+ ui.info(chalk.dim(`… ${ev.scanned} archive(s) read (current: ${ev.path})`));
370
+ }
371
+ } else if (ev.phase === "done") {
372
+ if (total && ui.isTTY) process.stdout.write("\r\x1b[K");
373
+ else if (total && !ui.isTTY) ui.info(chalk.dim(`scanned ${ev.scanned} archive(s) → ${ev.found} embedded coord(s)`));
374
+ }
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Run `fn` (sync or async) while telling the user which phase is in flight, so a
380
+ * long pause is attributable instead of a silent hang. TTY: a transient line that's
381
+ * cleared when done. Non-TTY: a plain "· <label> …" line. Either way, a phase that
382
+ * takes >3s prints "· <label> took Ns" so slow steps self-report even without -v.
383
+ */
384
+ async function timedPhase(label, fn) {
385
+ if (ui.isTTY) process.stdout.write(` ${chalk.dim("·")} ${chalk.dim(label + " …")}\x1b[K`);
386
+ else ui.info(chalk.dim(label + " …"));
387
+ const t0 = Date.now();
388
+ try {
389
+ return await fn();
390
+ } finally {
391
+ const ms = Date.now() - t0;
392
+ if (ui.isTTY) process.stdout.write("\r\x1b[K");
393
+ if (ms > 3000) ui.info(chalk.dim(`${label} took ${(ms / 1000).toFixed(1)}s`));
394
+ else if (verbose) ui.info(chalk.dim(`${label} done in ${ms}ms`));
286
395
  }
287
396
  }
288
397
 
@@ -292,13 +401,44 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
292
401
  // Build the Maven repo list once: persisted repos (from ~/.fad-checker/config.json)
293
402
  // + ad-hoc --repo URLs + Maven Central as final fallback. Used by transitive
294
403
  // resolution, outdated-version check, and existence check.
295
- const { getMavenRepos } = require("./lib/config");
404
+ const { getRegistryMap } = require("./lib/config");
296
405
  const { buildRepoList } = require("./lib/maven-repo");
297
- const extraRepos = (options.repo || []).map(url => ({ url }));
298
- const mavenRepos = buildRepoList(getMavenRepos(), extraRepos);
406
+ const { buildRegistryList } = require("./lib/registries");
407
+ // One-off --repo eco=url (from the CLI and the env layer), grouped by ecosystem.
408
+ const cliRepoMap = {};
409
+ for (const spec of [...(options.repo || []), ...(_layers.envRepos || [])]) {
410
+ const m = /^([a-z]+)=(.+)$/i.exec(String(spec));
411
+ if (!m) { console.error(chalk.red(`❌ --repo expects <ecosystem>=<url>, got "${spec}"`)); process.exit(1); }
412
+ (cliRepoMap[m[1]] ||= []).push({ url: m[2] });
413
+ }
414
+ // Union the registry sources: global config + config-file JSON + CLI/env one-offs.
415
+ const fileRegMap = (_layers.fileLayer && _layers.fileLayer.registries) || {};
416
+ const globalRegMap = getRegistryMap();
417
+ const regMap = {};
418
+ for (const eco of new Set([...Object.keys(globalRegMap), ...Object.keys(fileRegMap), ...Object.keys(cliRepoMap)])) {
419
+ regMap[eco] = buildRegistryList(eco, [globalRegMap[eco], fileRegMap[eco], cliRepoMap[eco]]);
420
+ }
421
+ const registriesFor = eco => regMap[eco] || [];
422
+ const mavenRepos = buildRepoList(regMap.maven || [], []); // appends Maven Central last
423
+
424
+ // Walk-pruning: union --exclude-path globs across every config layer (CLI + file
425
+ // + env + global), like registries. `defaultExcludes` (a scalar, false via
426
+ // --no-default-excludes) already flowed through applyLayers.
427
+ const excludePath = [...new Set([
428
+ ...(options.excludePath || []),
429
+ ...((_layers.fileLayer && _layers.fileLayer.excludePath) || []),
430
+ ...((_layers.envLayer && _layers.envLayer.excludePath) || []),
431
+ ...(require("./lib/config").get("excludePath") || []),
432
+ ].filter(Boolean))];
433
+ const defaultExcludes = options.defaultExcludes !== false;
434
+ const walkOpts = { excludePath, defaultExcludes };
299
435
  const runMode = options.importAnonymized ? "import descriptor" : (options.offline ? "offline" : "online");
300
436
  if (options.src) ui.kv("source", chalk.white(options.src));
301
437
  if (mavenRepos.length > 1) ui.kv("repos", chalk.white(mavenRepos.map(r => r.name).join(chalk.dim(" → "))));
438
+ const otherRegs = Object.keys(regMap).filter(e => e !== "maven" && regMap[e].length);
439
+ if (otherRegs.length) ui.kv("registries", chalk.white(otherRegs.map(e => `${e}:${regMap[e].length}`).join(" ")));
440
+ if (excludePath.length) ui.kv("exclude-path", chalk.white(excludePath.join(chalk.dim(", "))));
441
+ if (!defaultExcludes) ui.kv("default-excludes", chalk.yellow("off (walking node_modules/vendor/.git/…)"));
302
442
  ui.kv("mode", chalk.white(runMode));
303
443
 
304
444
  let wrotePom = 0;
@@ -324,7 +464,7 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
324
464
  const { warmRetireSignatures } = require("./lib/retire");
325
465
  await warmRetireSignatures({ verbose });
326
466
  }
327
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, collectWarnings: [] });
467
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, regMap, collectWarnings: [] });
328
468
  return;
329
469
  }
330
470
 
@@ -332,13 +472,24 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
332
472
  const { detectCodecs, allCodecs, getCodec } = require("./lib/codecs");
333
473
  const { resolveActiveCodecs } = require("./lib/codecs/select");
334
474
  const eco = (options.ecosystem || "auto").toLowerCase();
335
- const detected = (eco === "auto") ? detectCodecs(options.src).map(c => c.id) : allCodecs().map(c => c.id);
475
+ const detected = (eco === "auto")
476
+ ? (await timedPhase("detecting ecosystems", () => detectCodecs(options.src, walkOpts))).map(c => c.id)
477
+ : allCodecs().map(c => c.id);
478
+ // The binary scanner is a cross-cutting catch-all (committed native libs in ANY
479
+ // project), and detectCodecs' manifest-glob matcher misses versioned sonames
480
+ // (libz.so.1). Always make it a candidate in auto mode; --no-binaries removes it.
481
+ if (eco === "auto" && !detected.includes("binary")) detected.push("binary");
336
482
  const noCodecs = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby"].filter(id => options[id] === false);
483
+ // `--no-binaries` maps to options.binaries (plural) but the codec id is `binary`.
484
+ if (options.binaries === false) noCodecs.push("binary");
337
485
  const activeIds = resolveActiveCodecs(eco, detected, { noCodecs, noJs: !options.js });
338
486
  const runMaven = activeIds.includes("maven");
339
487
  const runNpm = activeIds.includes("npm") || activeIds.includes("yarn");
340
488
 
341
489
  // --- Collect deps from every active codec into one Map (coordKeys never collide) ---
490
+ // Section header first so the embedded-JAR scan can print live progress under it
491
+ // (the scan reads + unzips archives synchronously and would otherwise block silently).
492
+ ui.section("Collection");
342
493
  const resolved = new Map();
343
494
  let mavenCtx = null;
344
495
  const collectWarnings = [];
@@ -347,7 +498,7 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
347
498
  const codec = getCodec(id);
348
499
  let res;
349
500
  try {
350
- res = await codec.collect(options.src, { ignoreTest: !!options.ignoreTest, deps2Exclude, verbose, scanJars: options.jars !== false, srcRoot: options.src });
501
+ 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 }));
351
502
  } catch (err) {
352
503
  console.warn(chalk.red(`❌ ${id} collect failed:`), chalk.dim(err.message));
353
504
  continue;
@@ -358,11 +509,12 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
358
509
  }
359
510
 
360
511
  // --- Collection summary ---
361
- ui.section("Collection");
362
512
  const ecoCount = {};
363
513
  let embeddedCount = 0;
514
+ let binaryCount = 0;
364
515
  for (const d of resolved.values()) {
365
516
  if (d.provenance === "embedded") { embeddedCount++; continue; } // counted separately below
517
+ if (d.provenance === "binary") { binaryCount++; continue; } // committed native libs, no manifest
366
518
  ecoCount[d.ecosystem] = (ecoCount[d.ecosystem] || 0) + 1;
367
519
  }
368
520
  if (runMaven) ui.ok(`${chalk.bold("Maven".padEnd(8))} ${mavenCtx ? mavenCtx.pomFiles.length + " module(s) · " : ""}${ecoCount.maven || 0} direct dep(s)`);
@@ -372,6 +524,7 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
372
524
  ui.ok(`${chalk.bold(((getCodec(id)?.label) || id).padEnd(8))} ${n} dep(s)`);
373
525
  }
374
526
  if (embeddedCount) ui.ok(`${chalk.bold("Embedded".padEnd(8))} ${embeddedCount} coord(s) in .jar/.war/.ear`);
527
+ if (binaryCount) ui.ok(`${chalk.bold("Binary".padEnd(8))} ${binaryCount} native lib(s) (.dll/.exe/.so/.dylib)`);
375
528
  if (!ecoCount.maven && !ecoCount.npm && !Object.keys(ecoCount).length) ui.warn("no dependencies found in the source tree");
376
529
  if (collectWarnings.length) {
377
530
  ui.warn(`${collectWarnings.length} manifest warning(s) — best-effort / no lockfile:`);
@@ -432,7 +585,18 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
432
585
  ui.ok("no missing Maven parent POMs");
433
586
  }
434
587
 
588
+ // Private-lib detection asks each configured repo whether a missing coord
589
+ // exists (→ absent = likely private). Results are cached + bundled, so an
590
+ // --offline run reads the online-warmed cache instead of probing the network.
591
+ // A coord that's neither cached nor probeable (offline + cold) stays UNKNOWN —
592
+ // we never fake it as "private", which is what made offline both wrong and slow.
435
593
  if (options.allLibs) {
594
+ const { loadJsonCache, saveJsonCache } = require("./lib/outdated");
595
+ const existsCache = loadJsonCache(MAVEN_EXISTS_CACHE_PATH);
596
+ const fresh = existsCache.meta?.fetchedAt && (Date.now() - existsCache.meta.fetchedAt) < MAVEN_EXISTS_MAX_AGE_MS;
597
+ if (!fresh && !options.offline) existsCache.entries = {}; // refresh stale probes when online
598
+ if (!existsCache.entries) existsCache.entries = {};
599
+
436
600
  const anyMissingLibs = Object.keys(allPomMetadata.anyMissingById)
437
601
  .filter(id => {
438
602
  const parts = id.split(":");
@@ -442,14 +606,20 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
442
606
  const limit = pLimit(10);
443
607
  const results = await Promise.all(anyMissingLibs.map(id => {
444
608
  const [g, a] = id.split(":");
445
- return limit(async () => ({ id, found: await checkMavenLibExist(g, a, mavenRepos) }));
609
+ return limit(async () => ({ id, found: await checkMavenLibExist(g, a, mavenRepos, existsCache, { offline: options.offline }) }));
446
610
  }));
447
- for (const r of results) if (r && r.found === false) privateLibIds.push(r.id);
611
+ let unknown = 0;
612
+ for (const r of results) {
613
+ if (r.found === false) privateLibIds.push(r.id);
614
+ else if (r.found === null) unknown++;
615
+ }
616
+ if (!options.offline) { existsCache.meta = { fetchedAt: Date.now() }; saveJsonCache(MAVEN_EXISTS_CACHE_PATH, existsCache); }
448
617
  if (privateLibIds.length) {
449
618
  ui.warn(`${privateLibIds.length} lib(s) absent from Maven Central (likely private):`);
450
619
  for (const id of privateLibIds.slice(0, 10)) ui.info(chalk.magenta(id));
451
620
  if (privateLibIds.length > 10) ui.info(chalk.dim(`…and ${privateLibIds.length - 10} more`));
452
621
  }
622
+ 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
623
  }
454
624
 
455
625
  if (deps2Exclude) {
@@ -476,7 +646,7 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
476
646
  // The scan always runs — it feeds the terminal summary, the file outputs and the
477
647
  // CI gate. Which files get written is decided by the --report-* family inside
478
648
  // (HTML + .doc by default; --no-report writes nothing).
479
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, collectWarnings });
649
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings });
480
650
  if (!readOnly) {
481
651
  ui.section("Next step");
482
652
  ui.info(`run Snyk on the cleaned tree:`);
@@ -485,7 +655,8 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
485
655
  })();
486
656
 
487
657
  async function runReportFlow(resolved, ecoFlags = {}) {
488
- const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], collectWarnings = [] } = ecoFlags;
658
+ const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [] } = ecoFlags;
659
+ const registriesFor = eco => regMap[eco] || [];
489
660
  const { expandWithTransitives } = require("./lib/cve-match");
490
661
  const { writeReports, computeStats } = require("./lib/cve-report");
491
662
  const { getCodec } = require("./lib/codecs");
@@ -530,9 +701,11 @@ async function runReportFlow(resolved, ecoFlags = {}) {
530
701
  const willKev = !!options.kev;
531
702
  const willLicenses = !!options.licenses;
532
703
  const willRetire = !!options.retire;
704
+ // Identify committed native binaries by checksum (deps.dev + CIRCL) when present.
705
+ const willBinaryId = [...resolved.values()].some(d => d.provenance === "binary");
533
706
  // License detection piggybacks on the registry passes (same fetched metadata),
534
707
  // so it adds no progress step of its own.
535
- const totalSteps = [willTransitive, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire].filter(Boolean).length;
708
+ const totalSteps = [willTransitive, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
536
709
  const progress = new ui.Progress(totalSteps);
537
710
 
538
711
  if (willTransitive) {
@@ -562,6 +735,17 @@ async function runReportFlow(resolved, ecoFlags = {}) {
562
735
  }
563
736
  }
564
737
 
738
+ // 1c. Identify committed native binaries by checksum (deps.dev → CIRCL).
739
+ if (willBinaryId) {
740
+ const st = progress.start("Binary identification (deps.dev + CIRCL)");
741
+ try {
742
+ const { enrichUnmanaged } = require("./lib/unmanaged");
743
+ const s = await enrichUnmanaged(resolved, { offline, onProgress: (p, t) => st.tick(p, t) });
744
+ 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(", ");
745
+ st.done(bits);
746
+ } catch (err) { st.fail(err.message); }
747
+ }
748
+
565
749
  // 2. EOL frameworks (endoflife.date) — always a step.
566
750
  let eolResults = [];
567
751
  {
@@ -595,7 +779,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
595
779
  const st = progress.start("npm registry");
596
780
  try {
597
781
  const { checkNpmRegistryDeps } = require("./lib/codecs/npm/registry");
598
- const npmReg = await checkNpmRegistryDeps(resolved, { verbose, offline, allLibs: options.allLibs, onProgress: (p, t) => st.tick(p, t) });
782
+ const npmReg = await checkNpmRegistryDeps(resolved, { verbose, offline, allLibs: options.allLibs, registries: registriesFor("npm"), onProgress: (p, t) => st.tick(p, t) });
599
783
  obsoleteResults = obsoleteResults.concat(npmReg.deprecated);
600
784
  outdatedResults = outdatedResults.concat(npmReg.outdated);
601
785
  licenseFindings = licenseFindings.concat(npmReg.licensed || []);
@@ -608,7 +792,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
608
792
  const codec = getCodec(id);
609
793
  const st = progress.start(`${codec.label || id} registry`);
610
794
  try {
611
- const reg = await codec.checkRegistry(resolved, { verbose, offline, allLibs: options.allLibs, onProgress: (p, t) => st.tick(p, t) });
795
+ const reg = await codec.checkRegistry(resolved, { verbose, offline, allLibs: options.allLibs, registries: registriesFor(id), onProgress: (p, t) => st.tick(p, t) });
612
796
  obsoleteResults = obsoleteResults.concat(reg.deprecated || []);
613
797
  outdatedResults = outdatedResults.concat(reg.outdated || []);
614
798
  licenseFindings = licenseFindings.concat(reg.licensed || []);
@@ -702,6 +886,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
702
886
  // vendored .js (which can live in a Maven project's resources too). The
703
887
  // scanner is owned by the npm codec but runs whenever --retire is on.
704
888
  let retireMatches = [];
889
+ let vendoredJsInventory = [];
705
890
  if (willRetire) {
706
891
  const st = progress.start("retire.js (vendored JS)");
707
892
  const sc = (getCodec("npm").nativeScanners || []).find(s => s.kind === "vendored");
@@ -711,7 +896,9 @@ async function runReportFlow(resolved, ecoFlags = {}) {
711
896
  try {
712
897
  const r = await sc.scan(resolved, { src: options.src, verbose, retireRefresh: !!options.retireRefresh, offline });
713
898
  retireMatches = r.matches || [];
714
- st.done(`${retireMatches.length} finding(s)`);
899
+ if (options.vendoredJsInventory !== false) vendoredJsInventory = r.meta?.inventory || [];
900
+ const invN = vendoredJsInventory.length;
901
+ st.done(`${retireMatches.length} finding(s)${invN ? ` · ${invN} lib(s) inventoried` : ""}`);
715
902
  } catch (err) { st.fail(err.message); }
716
903
  }
717
904
  }
@@ -774,6 +961,15 @@ async function runReportFlow(resolved, ecoFlags = {}) {
774
961
  const sev = ui.sevColor;
775
962
  const depLabel = d => d.ecosystem === "npm" ? `npm:${d.artifactId}` : `${d.groupId}:${d.artifactId}`;
776
963
  const coordOf = depLabel; // npm deps show as "npm:name", others as "g:a"
964
+ // Where a finding's dependency was declared — the pom.xml / package.json / jar
965
+ // (embedded jars carry a "app.jar!/BOOT-INF/lib/…" manifestPath). Shown so EOL /
966
+ // obsolete / outdated entries point at the file the reader has to edit.
967
+ const definedInOf = d => {
968
+ const paths = (d?.manifestPaths?.length ? d.manifestPaths : d?.pomPaths?.length ? d.pomPaths : []);
969
+ if (!paths.length) return "";
970
+ const rel = paths.map(p => { try { return path.relative(options.src, p); } catch { return p; } });
971
+ return chalk.dim(` ← ${rel[0]}${rel.length > 1 ? ` (+${rel.length - 1})` : ""}`);
972
+ };
777
973
  const fmtStats = s => [
778
974
  s.critical ? sev("CRITICAL")(`${s.critical} critical`) : null,
779
975
  s.high ? sev("HIGH")(`${s.high} high`) : null,
@@ -805,16 +1001,30 @@ async function runReportFlow(resolved, ecoFlags = {}) {
805
1001
  if (embeddedActive.length > 8) console.log(chalk.dim(` …and ${embeddedActive.length - 8} more (see report ch.1B)`));
806
1002
  }
807
1003
 
1004
+ {
1005
+ const { buildInventory } = require("./lib/unmanaged");
1006
+ const inv = buildInventory(resolved);
1007
+ if (inv.length) {
1008
+ heading("Unmanaged binaries", inv.length);
1009
+ for (const e of inv.slice(0, 10)) {
1010
+ const id = e.identity ? `${e.identity.ecosystem ? e.identity.ecosystem + ":" : ""}${e.identity.name || ""}${e.identity.version ? "@" + e.identity.version : ""}` : chalk.dim("unknown");
1011
+ 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(" ");
1012
+ console.log(" " + chalk.white(path.basename(String(e.path))) + " " + chalk.dim(id) + (flags ? " " + flags : ""));
1013
+ }
1014
+ if (inv.length > 10) console.log(chalk.dim(` …and ${inv.length - 10} more (see report ch.1C)`));
1015
+ }
1016
+ }
1017
+
808
1018
  heading("EOL frameworks", eolResults.length);
809
- 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)));
1019
+ 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));
810
1020
  if (eolResults.length > 8) console.log(chalk.dim(` …and ${eolResults.length - 8} more`));
811
1021
 
812
1022
  heading("Obsolete / deprecated", obsoleteResults.length);
813
- for (const o of obsoleteResults.slice(0, 8)) console.log(" " + chalk.dim(`${coordOf(o.dep)}:${o.dep.version}`) + " → " + (o.replacement || chalk.dim("n/a")));
1023
+ 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));
814
1024
  if (obsoleteResults.length > 8) console.log(chalk.dim(` …and ${obsoleteResults.length - 8} more`));
815
1025
 
816
1026
  heading("Outdated", outdatedResults.length, options.allLibs ? "" : chalk.dim("pass -a/--allLibs to query registries"));
817
- for (const o of outdatedResults.slice(0, 8)) console.log(" " + chalk.dim(coordOf(o.dep)) + ` ${o.dep.version} → ${chalk.green(o.latest)}`);
1027
+ 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));
818
1028
  if (outdatedResults.length > 8) console.log(chalk.dim(` …and ${outdatedResults.length - 8} more`));
819
1029
 
820
1030
  if (retireMatches.length) {
@@ -908,7 +1118,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
908
1118
  if (out.html || out.doc) {
909
1119
  await ensureDir(out.html); await ensureDir(out.doc);
910
1120
  const { htmlPath, docPath } = await writeReports({
911
- cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches,
1121
+ cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches, vendoredJsInventory,
912
1122
  eolResults, obsoleteResults, outdatedResults, licenseResults,
913
1123
  resolvedDeps: resolved, projectInfo, warnings: reportWarnings,
914
1124
  htmlPath: out.html, docPath: out.doc,
@@ -939,7 +1149,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
939
1149
  try {
940
1150
  const { writeFindings } = require("./lib/json-export");
941
1151
  await ensureDir(out.json);
942
- writeFindings({ cveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version }, out.json);
1152
+ writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version }, out.json);
943
1153
  wrote.push(["Findings JSON", out.json]);
944
1154
  } catch (err) { ui.warn(`JSON export failed: ${err.message}`); }
945
1155
  }
@@ -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 };