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/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 (Array.isArray(masked.maven_repos)) {
65
- masked.maven_repos = masked.maven_repos.map(r => ({ ...r, auth: r.auth ? "***" : undefined }));
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 repos = config.getMavenRepos();
77
- if (!repos.length) {
78
- console.log(chalk.gray("No custom Maven repos configured (Maven Central is always used as fallback)."));
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
- console.log(chalk.bold("Configured Maven repos (tried in order, then Central):"));
81
- for (const r of repos) {
82
- const authMark = r.auth ? chalk.yellow(" [auth]") : "";
83
- console.log(` • ${chalk.cyan(r.name)} ${r.url}${authMark}`);
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
- const url = process.argv[idx + 2];
92
- if (!name || name.startsWith("-") || !url || url.startsWith("-")) {
93
- console.error(chalk.red("--add-repo requires <name> <url>"));
94
- console.error(" Example: fad-checker --add-repo nexus https://nexus.acme.com/repository/maven-public/");
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 auth = authIdx > -1 ? process.argv[authIdx + 1] : null;
100
- config.addMavenRepo(name, url, auth);
101
- console.log(chalk.green(`✅ Added Maven repo "${name}" → ${url}${auth ? " (with auth)" : ""}`));
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
- const removed = config.removeMavenRepo(name);
112
- console.log(removed ? chalk.green(`✅ Removed Maven repo "${name}"`) : chalk.yellow(`⚠️ No Maven repo named "${name}"`));
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("-e, --exclude <exclude>", "regex of groupId to exclude, e.g. '^(client|private)\\.'")
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("--no-licenses", "skip license detection + copyleft policy check")
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 Maven repository URL(s) to try before Maven Central. Supports https://user:pass@host/path/. Repeatable.")
224
- .option("--add-repo <name>", "persist a Maven repo: --add-repo <name> <url> [--auth user:pass]")
225
- .option("--remove-repo <name>", "remove a persisted Maven repo by name")
226
- .option("--list-repos", "list configured Maven repos and exit")
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 { getMavenRepos } = require("./lib/config");
420
+ const { getRegistryMap } = require("./lib/config");
366
421
  const { buildRepoList } = require("./lib/maven-repo");
367
- const extraRepos = (options.repo || []).map(url => ({ url }));
368
- const mavenRepos = buildRepoList(getMavenRepos(), extraRepos);
369
- const runMode = options.importAnonymized ? "import descriptor" : (options.offline ? "offline" : "online");
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
- st.done(`${retireMatches.length} finding(s)`);
806
- } catch (err) { st.fail(err.message); }
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 };