fad-checker 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fad-checker.js CHANGED
@@ -61,8 +61,9 @@ if (process.argv.includes("--show-config")) {
61
61
  const cfg = config.load();
62
62
  const masked = { ...cfg };
63
63
  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 }));
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 }))]));
66
67
  }
67
68
  console.log(JSON.stringify(masked, null, 2));
68
69
  console.log(chalk.gray("Config file: " + config.CONFIG_PATH));
@@ -72,44 +73,58 @@ if (process.argv.includes("--show-config")) {
72
73
  // -------- --add-repo / --remove-repo / --list-repos (run before program.parse) --------
73
74
  if (process.argv.includes("--add-repo") || process.argv.includes("--remove-repo") || process.argv.includes("--list-repos")) {
74
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
+ };
75
83
  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)."));
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)."));
79
88
  } 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}`);
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
+ }
84
95
  }
85
96
  }
86
97
  process.exit(0);
87
98
  }
88
99
  if (process.argv.includes("--add-repo")) {
89
100
  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");
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");
96
106
  process.exit(1);
97
107
  }
108
+ ecoErr(eco);
98
109
  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)" : ""}`));
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}`));
102
116
  process.exit(0);
103
117
  }
104
118
  if (process.argv.includes("--remove-repo")) {
105
119
  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>"));
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>"));
109
123
  process.exit(1);
110
124
  }
111
- const removed = config.removeMavenRepo(name);
112
- 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}"`));
113
128
  process.exit(removed ? 0 : 1);
114
129
  }
115
130
  }
@@ -169,7 +184,11 @@ program
169
184
  .option("-t, --target <target>", "output directory (will be rm before written). If omitted, the run is read-only.")
170
185
  // Not a requiredOption: --import-anonymized scans a descriptor with no source tree.
171
186
  .option("-s, --src <src>", "root directory containing pom.xml files")
172
- .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")
173
192
  .option("-v, --verbose", "verbose")
174
193
  // Defaults: report + transitive + allLibs all ON. Use --no-* to disable.
175
194
  .option("--no-report", "write NO output files at all — the scan, terminal summary and --fail-on gate still run (gate-only / CI mode)")
@@ -207,6 +226,7 @@ program
207
226
  .option("--cve-offline", "use cached CVE index only (no download)")
208
227
  .option("--snyk", "run snyk on cleaned POMs and merge into report (requires --target)")
209
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)")
210
230
  .option("--retire-refresh", "ignore retire cache and re-scan")
211
231
  .option("--transitive-depth <n>", "max transitive depth", "6")
212
232
  .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 +238,35 @@ program
218
238
  .option("--no-pypi", "skip the PyPI (Python) codec")
219
239
  .option("--no-go", "skip the Go codec")
220
240
  .option("--no-ruby", "skip the Ruby (Bundler) codec")
241
+ .option("--no-binaries", "skip scanning committed native binaries (.dll/.exe/.so/.dylib)")
221
242
  .option("--no-jars", "skip scanning embedded .jar/.war/.ear binaries for Maven coordinates")
222
243
  .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")
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")
227
250
  .option("--completion <shell>", "print shell completion script (bash|zsh)");
228
251
  program.parse(process.argv);
229
252
 
230
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
+
231
270
  const deps2Exclude = options.exclude ? new RegExp(options.exclude) : null;
232
271
  const verbose = !!options.verbose;
233
272
 
@@ -362,13 +401,44 @@ async function timedPhase(label, fn) {
362
401
  // Build the Maven repo list once: persisted repos (from ~/.fad-checker/config.json)
363
402
  // + ad-hoc --repo URLs + Maven Central as final fallback. Used by transitive
364
403
  // resolution, outdated-version check, and existence check.
365
- const { getMavenRepos } = require("./lib/config");
404
+ const { getRegistryMap } = require("./lib/config");
366
405
  const { buildRepoList } = require("./lib/maven-repo");
367
- const extraRepos = (options.repo || []).map(url => ({ url }));
368
- 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 };
369
435
  const runMode = options.importAnonymized ? "import descriptor" : (options.offline ? "offline" : "online");
370
436
  if (options.src) ui.kv("source", chalk.white(options.src));
371
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/…)"));
372
442
  ui.kv("mode", chalk.white(runMode));
373
443
 
374
444
  let wrotePom = 0;
@@ -394,7 +464,7 @@ async function timedPhase(label, fn) {
394
464
  const { warmRetireSignatures } = require("./lib/retire");
395
465
  await warmRetireSignatures({ verbose });
396
466
  }
397
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, collectWarnings: [] });
467
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, regMap, collectWarnings: [] });
398
468
  return;
399
469
  }
400
470
 
@@ -403,9 +473,15 @@ async function timedPhase(label, fn) {
403
473
  const { resolveActiveCodecs } = require("./lib/codecs/select");
404
474
  const eco = (options.ecosystem || "auto").toLowerCase();
405
475
  const detected = (eco === "auto")
406
- ? (await timedPhase("detecting ecosystems", () => detectCodecs(options.src))).map(c => c.id)
476
+ ? (await timedPhase("detecting ecosystems", () => detectCodecs(options.src, walkOpts))).map(c => c.id)
407
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");
408
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");
409
485
  const activeIds = resolveActiveCodecs(eco, detected, { noCodecs, noJs: !options.js });
410
486
  const runMaven = activeIds.includes("maven");
411
487
  const runNpm = activeIds.includes("npm") || activeIds.includes("yarn");
@@ -422,7 +498,7 @@ async function timedPhase(label, fn) {
422
498
  const codec = getCodec(id);
423
499
  let res;
424
500
  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() }));
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 }));
426
502
  } catch (err) {
427
503
  console.warn(chalk.red(`❌ ${id} collect failed:`), chalk.dim(err.message));
428
504
  continue;
@@ -435,8 +511,10 @@ async function timedPhase(label, fn) {
435
511
  // --- Collection summary ---
436
512
  const ecoCount = {};
437
513
  let embeddedCount = 0;
514
+ let binaryCount = 0;
438
515
  for (const d of resolved.values()) {
439
516
  if (d.provenance === "embedded") { embeddedCount++; continue; } // counted separately below
517
+ if (d.provenance === "binary") { binaryCount++; continue; } // committed native libs, no manifest
440
518
  ecoCount[d.ecosystem] = (ecoCount[d.ecosystem] || 0) + 1;
441
519
  }
442
520
  if (runMaven) ui.ok(`${chalk.bold("Maven".padEnd(8))} ${mavenCtx ? mavenCtx.pomFiles.length + " module(s) · " : ""}${ecoCount.maven || 0} direct dep(s)`);
@@ -446,6 +524,7 @@ async function timedPhase(label, fn) {
446
524
  ui.ok(`${chalk.bold(((getCodec(id)?.label) || id).padEnd(8))} ${n} dep(s)`);
447
525
  }
448
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)`);
449
528
  if (!ecoCount.maven && !ecoCount.npm && !Object.keys(ecoCount).length) ui.warn("no dependencies found in the source tree");
450
529
  if (collectWarnings.length) {
451
530
  ui.warn(`${collectWarnings.length} manifest warning(s) — best-effort / no lockfile:`);
@@ -567,7 +646,7 @@ async function timedPhase(label, fn) {
567
646
  // The scan always runs — it feeds the terminal summary, the file outputs and the
568
647
  // CI gate. Which files get written is decided by the --report-* family inside
569
648
  // (HTML + .doc by default; --no-report writes nothing).
570
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, collectWarnings });
649
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings });
571
650
  if (!readOnly) {
572
651
  ui.section("Next step");
573
652
  ui.info(`run Snyk on the cleaned tree:`);
@@ -576,7 +655,8 @@ async function timedPhase(label, fn) {
576
655
  })();
577
656
 
578
657
  async function runReportFlow(resolved, ecoFlags = {}) {
579
- 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] || [];
580
660
  const { expandWithTransitives } = require("./lib/cve-match");
581
661
  const { writeReports, computeStats } = require("./lib/cve-report");
582
662
  const { getCodec } = require("./lib/codecs");
@@ -621,9 +701,11 @@ async function runReportFlow(resolved, ecoFlags = {}) {
621
701
  const willKev = !!options.kev;
622
702
  const willLicenses = !!options.licenses;
623
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");
624
706
  // License detection piggybacks on the registry passes (same fetched metadata),
625
707
  // 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;
708
+ const totalSteps = [willTransitive, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
627
709
  const progress = new ui.Progress(totalSteps);
628
710
 
629
711
  if (willTransitive) {
@@ -653,6 +735,17 @@ async function runReportFlow(resolved, ecoFlags = {}) {
653
735
  }
654
736
  }
655
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
+
656
749
  // 2. EOL frameworks (endoflife.date) — always a step.
657
750
  let eolResults = [];
658
751
  {
@@ -686,7 +779,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
686
779
  const st = progress.start("npm registry");
687
780
  try {
688
781
  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) });
782
+ const npmReg = await checkNpmRegistryDeps(resolved, { verbose, offline, allLibs: options.allLibs, registries: registriesFor("npm"), onProgress: (p, t) => st.tick(p, t) });
690
783
  obsoleteResults = obsoleteResults.concat(npmReg.deprecated);
691
784
  outdatedResults = outdatedResults.concat(npmReg.outdated);
692
785
  licenseFindings = licenseFindings.concat(npmReg.licensed || []);
@@ -699,7 +792,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
699
792
  const codec = getCodec(id);
700
793
  const st = progress.start(`${codec.label || id} registry`);
701
794
  try {
702
- 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) });
703
796
  obsoleteResults = obsoleteResults.concat(reg.deprecated || []);
704
797
  outdatedResults = outdatedResults.concat(reg.outdated || []);
705
798
  licenseFindings = licenseFindings.concat(reg.licensed || []);
@@ -793,6 +886,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
793
886
  // vendored .js (which can live in a Maven project's resources too). The
794
887
  // scanner is owned by the npm codec but runs whenever --retire is on.
795
888
  let retireMatches = [];
889
+ let vendoredJsInventory = [];
796
890
  if (willRetire) {
797
891
  const st = progress.start("retire.js (vendored JS)");
798
892
  const sc = (getCodec("npm").nativeScanners || []).find(s => s.kind === "vendored");
@@ -802,7 +896,9 @@ async function runReportFlow(resolved, ecoFlags = {}) {
802
896
  try {
803
897
  const r = await sc.scan(resolved, { src: options.src, verbose, retireRefresh: !!options.retireRefresh, offline });
804
898
  retireMatches = r.matches || [];
805
- 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` : ""}`);
806
902
  } catch (err) { st.fail(err.message); }
807
903
  }
808
904
  }
@@ -865,6 +961,15 @@ async function runReportFlow(resolved, ecoFlags = {}) {
865
961
  const sev = ui.sevColor;
866
962
  const depLabel = d => d.ecosystem === "npm" ? `npm:${d.artifactId}` : `${d.groupId}:${d.artifactId}`;
867
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
+ };
868
973
  const fmtStats = s => [
869
974
  s.critical ? sev("CRITICAL")(`${s.critical} critical`) : null,
870
975
  s.high ? sev("HIGH")(`${s.high} high`) : null,
@@ -896,16 +1001,30 @@ async function runReportFlow(resolved, ecoFlags = {}) {
896
1001
  if (embeddedActive.length > 8) console.log(chalk.dim(` …and ${embeddedActive.length - 8} more (see report ch.1B)`));
897
1002
  }
898
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
+
899
1018
  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)));
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));
901
1020
  if (eolResults.length > 8) console.log(chalk.dim(` …and ${eolResults.length - 8} more`));
902
1021
 
903
1022
  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")));
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));
905
1024
  if (obsoleteResults.length > 8) console.log(chalk.dim(` …and ${obsoleteResults.length - 8} more`));
906
1025
 
907
1026
  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)}`);
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));
909
1028
  if (outdatedResults.length > 8) console.log(chalk.dim(` …and ${outdatedResults.length - 8} more`));
910
1029
 
911
1030
  if (retireMatches.length) {
@@ -999,7 +1118,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
999
1118
  if (out.html || out.doc) {
1000
1119
  await ensureDir(out.html); await ensureDir(out.doc);
1001
1120
  const { htmlPath, docPath } = await writeReports({
1002
- cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches,
1121
+ cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches, vendoredJsInventory,
1003
1122
  eolResults, obsoleteResults, outdatedResults, licenseResults,
1004
1123
  resolvedDeps: resolved, projectInfo, warnings: reportWarnings,
1005
1124
  htmlPath: out.html, docPath: out.doc,
@@ -1030,7 +1149,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1030
1149
  try {
1031
1150
  const { writeFindings } = require("./lib/json-export");
1032
1151
  await ensureDir(out.json);
1033
- 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);
1034
1153
  wrote.push(["Findings JSON", out.json]);
1035
1154
  } catch (err) { ui.warn(`JSON export failed: ${err.message}`); }
1036
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 };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * lib/codecs/binary/sniff.js — file-type confirmation for the binary codec.
3
+ *
4
+ * Two gates: extKind() (extension allowlist) AND sniffKind() (magic bytes). A
5
+ * candidate is accepted only when both agree, so an image renamed `.so` (PNG
6
+ * magic) is rejected. We never trust the extension alone.
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
10
+ */
11
+
12
+ // Magic-byte signatures → family. Mach-O has four (32/64-bit + fat, both endian).
13
+ function sniffKind(buf) {
14
+ if (!buf || buf.length < 4) return null;
15
+ const b0 = buf[0], b1 = buf[1], b2 = buf[2], b3 = buf[3];
16
+ if (b0 === 0x4d && b1 === 0x5a) return "pe"; // MZ
17
+ if (b0 === 0x7f && b1 === 0x45 && b2 === 0x4c && b3 === 0x46) return "elf"; // \x7FELF
18
+ if (b0 === 0x50 && b1 === 0x4b && b2 === 0x03 && b3 === 0x04) return "zip"; // PK\x03\x04
19
+ const be = ((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) >>> 0;
20
+ const le = ((b3 << 24) | (b2 << 16) | (b1 << 8) | b0) >>> 0;
21
+ const machO = new Set([0xfeedface, 0xfeedfacf, 0xcafebabe, 0xcafebabf]);
22
+ if (machO.has(be) || machO.has(le)) return "macho";
23
+ return null;
24
+ }
25
+
26
+ const EXT_PE = /\.(dll|exe)$/i;
27
+ const EXT_MACHO = /\.dylib$/i;
28
+ const EXT_ELF = /\.so(\.\d+)*$/i; // .so, .so.1, .so.1.2.3
29
+
30
+ function extKind(name) {
31
+ if (EXT_PE.test(name)) return "pe";
32
+ if (EXT_MACHO.test(name)) return "macho";
33
+ if (EXT_ELF.test(name)) return "elf";
34
+ return null;
35
+ }
36
+
37
+ module.exports = { sniffKind, extKind };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * lib/codecs/binary.codec.js — codec for committed NATIVE binaries
3
+ * (.dll/.exe/.so/.dylib) that no package manager governs.
4
+ *
5
+ * Plan 1 scope: discover + hash only. The records carry no resolved coordinate
6
+ * (just a filename); Plan 2's hash-id service fills `identity`, Plan 3 builds the
7
+ * unmanaged inventory + report. Until then the records are `provenance:"binary"`
8
+ * and the CVE/OSV/EOL/outdated stages skip them (no coordinate to query).
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
12
+ */
13
+ const { makeDepRecord } = require("../dep-record");
14
+ const { scanBinaries } = require("./binary/scan");
15
+
16
+ module.exports = {
17
+ id: "binary",
18
+ label: "Binaries",
19
+ osvEcosystem: null, // no OSV ecosystem until identified
20
+ manifestNames: ["*.dll", "*.exe", "*.so", "*.dylib"], // lets detectCodecs include us in auto mode
21
+
22
+ detect(dir) {
23
+ try { return scanBinaries(dir, { onProgress: null }).length > 0; }
24
+ catch { return false; }
25
+ },
26
+
27
+ async collect(dir, opts = {}) {
28
+ const out = new Map();
29
+ const warnings = [];
30
+ let records;
31
+ try { records = scanBinaries(dir, { onProgress: opts.onBinaryProgress, srcRoot: opts.srcRoot || dir, excludePath: opts.excludePath, defaultExcludes: opts.defaultExcludes }); }
32
+ catch (e) { return { deps: out, warnings: [{ type: "scan-error", message: `binary scan failed: ${e.message}` }] }; }
33
+ for (const r of records) {
34
+ out.set(`binary:${r.path}`, makeDepRecord({
35
+ ecosystem: "binary",
36
+ name: r.declaredName,
37
+ version: null,
38
+ manifestPath: r.path,
39
+ provenance: "binary",
40
+ hashes: { sha1: r.sha1, sha256: r.sha256 },
41
+ declaredName: r.declaredName,
42
+ }));
43
+ }
44
+ return { deps: out, warnings };
45
+ },
46
+
47
+ coordKey(d) { return `binary:${d.manifestPaths?.[0] || d.name}`; },
48
+ formatCoord(d) { return d.declaredName || d.name; },
49
+ osvPackageName() { return null; },
50
+ async checkRegistry() { return { deprecated: [], outdated: [], licensed: [] }; },
51
+ resolveEolProduct() { return null; },
52
+ recipe: require("./recipes").binary,
53
+ nativeScanners: [],
54
+ };
@@ -15,7 +15,7 @@ const { parseComposerLock, parseComposerJson, isConcrete } = require("./composer
15
15
 
16
16
  const SKIP = new Set(["vendor", ".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target"]);
17
17
 
18
- function findComposerManifests(dir) {
18
+ function findComposerManifests(dir, skipDir = (child, name) => SKIP.has(name)) {
19
19
  const groups = [];
20
20
  const stack = [dir];
21
21
  while (stack.length) {
@@ -29,11 +29,15 @@ function findComposerManifests(dir) {
29
29
  composerLock: names.has("composer.lock") ? path.join(cur, "composer.lock") : null,
30
30
  });
31
31
  }
32
- for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
32
+ for (const e of entries) if (e.isDirectory() && !skipDir(path.join(cur, e.name), e.name)) stack.push(path.join(cur, e.name));
33
33
  }
34
34
  return groups;
35
35
  }
36
36
 
37
+ function dirFilter(dir, opts) {
38
+ return require("../path-filter").makeDirFilter({ srcRoot: opts.srcRoot || dir, defaultSkip: SKIP, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
39
+ }
40
+
37
41
  module.exports = {
38
42
  id: "composer",
39
43
  label: "Composer",
@@ -46,7 +50,7 @@ module.exports = {
46
50
  const { ignoreTest, deps2Exclude } = opts;
47
51
  const out = new Map();
48
52
  const warnings = [];
49
- for (const g of findComposerManifests(dir)) {
53
+ for (const g of findComposerManifests(dir, dirFilter(dir, opts))) {
50
54
  if (g.composerLock) {
51
55
  let parsed;
52
56
  try { parsed = parseComposerLock(g.composerLock); }