fad-checker 2.0.0 → 2.1.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 (139) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +147 -25
  3. package/completions/fad-checker.bash +1 -1
  4. package/completions/fad-checker.zsh +14 -1
  5. package/data/eol-mapping.json +5 -5
  6. package/data/license-policy.json +97 -0
  7. package/fad-checker-report/cve-report.doc +630 -0
  8. package/fad-checker-report/cve-report.html +748 -0
  9. package/fad-checker.js +556 -245
  10. package/lib/cache-archive.js +3 -0
  11. package/lib/codecs/codec.interface.js +3 -0
  12. package/lib/codecs/composer/parse.js +3 -0
  13. package/lib/codecs/composer/registry.js +13 -5
  14. package/lib/codecs/composer.codec.js +3 -0
  15. package/lib/codecs/go/parse.js +65 -0
  16. package/lib/codecs/go/registry.js +74 -0
  17. package/lib/codecs/go.codec.js +76 -0
  18. package/lib/codecs/index.js +7 -2
  19. package/lib/codecs/maven/jar-scan.js +199 -0
  20. package/lib/codecs/maven.codec.js +17 -1
  21. package/lib/codecs/npm/collect.js +37 -17
  22. package/lib/codecs/npm/parse.js +117 -10
  23. package/lib/codecs/npm/registry.js +14 -5
  24. package/lib/codecs/npm.codec.js +4 -1
  25. package/lib/codecs/nuget/parse.js +3 -0
  26. package/lib/codecs/nuget/registry.js +11 -4
  27. package/lib/codecs/nuget.codec.js +7 -2
  28. package/lib/codecs/pypi/parse.js +111 -14
  29. package/lib/codecs/pypi/registry.js +24 -5
  30. package/lib/codecs/pypi.codec.js +30 -6
  31. package/lib/codecs/recipes.js +28 -1
  32. package/lib/codecs/ruby/parse.js +42 -0
  33. package/lib/codecs/ruby/registry.js +76 -0
  34. package/lib/codecs/ruby.codec.js +66 -0
  35. package/lib/codecs/select.js +3 -0
  36. package/lib/codecs/yarn.codec.js +3 -0
  37. package/lib/config.js +3 -0
  38. package/lib/core.js +38 -1
  39. package/lib/cpe.js +30 -5
  40. package/lib/csaf-export.js +159 -0
  41. package/lib/cve-download.js +3 -0
  42. package/lib/cve-match.js +27 -6
  43. package/lib/cve-report.js +157 -28
  44. package/lib/dep-record.js +15 -2
  45. package/lib/deps-descriptor.js +113 -0
  46. package/lib/epss.js +115 -0
  47. package/lib/gate.js +45 -0
  48. package/lib/json-export.js +110 -0
  49. package/lib/kev.js +88 -0
  50. package/lib/license-policy.js +110 -0
  51. package/lib/maven-license.js +52 -0
  52. package/lib/maven-repo.js +3 -0
  53. package/lib/maven-version.js +8 -3
  54. package/lib/nvd.js +17 -6
  55. package/lib/osv.js +75 -23
  56. package/lib/outdated.js +10 -2
  57. package/lib/priority.js +90 -0
  58. package/lib/purl.js +77 -0
  59. package/lib/retire.js +80 -13
  60. package/lib/sarif-export.js +134 -0
  61. package/lib/sbom-export.js +153 -0
  62. package/lib/scan-completeness.js +3 -0
  63. package/lib/snyk.js +3 -0
  64. package/lib/suppress.js +113 -0
  65. package/lib/transitive.js +6 -3
  66. package/lib/ui.js +90 -0
  67. package/package.json +49 -2
  68. package/CLAUDE.md +0 -147
  69. package/docs/ARCHITECTURE.md +0 -182
  70. package/docs/USAGE.md +0 -187
  71. package/docs/superpowers/plans/2026-05-29-codec-composer.md +0 -556
  72. package/docs/superpowers/plans/2026-05-29-codec-foundation.md +0 -851
  73. package/docs/superpowers/plans/2026-05-29-codec-nuget.md +0 -432
  74. package/docs/superpowers/plans/2026-05-29-codec-pypi.md +0 -450
  75. package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +0 -251
  76. package/test/cli-ecosystem.test.js +0 -30
  77. package/test/codec-edge-cases.test.js +0 -131
  78. package/test/codec-integration.test.js +0 -119
  79. package/test/codecs.test.js +0 -92
  80. package/test/composer.test.js +0 -71
  81. package/test/core.test.js +0 -153
  82. package/test/cpe.test.js +0 -166
  83. package/test/cve-download.test.js +0 -39
  84. package/test/cve-match.test.js +0 -217
  85. package/test/cve-report.test.js +0 -180
  86. package/test/dep-record.test.js +0 -31
  87. package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
  88. package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
  89. package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
  90. package/test/fixtures/complex-enterprise/pom.xml +0 -66
  91. package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
  92. package/test/fixtures/csharp-config/packages.config +0 -4
  93. package/test/fixtures/csharp-csproj/Directory.Packages.props +0 -5
  94. package/test/fixtures/csharp-csproj/app.csproj +0 -7
  95. package/test/fixtures/csharp-lock/packages.lock.json +0 -6
  96. package/test/fixtures/cve-samples/cve-non-java.json +0 -19
  97. package/test/fixtures/cve-samples/cve-product-only.json +0 -31
  98. package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
  99. package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
  100. package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
  101. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
  102. package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
  103. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
  104. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -10
  105. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
  106. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
  107. package/test/fixtures/monorepo-mixed/pom.xml +0 -29
  108. package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
  109. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
  110. package/test/fixtures/php-app/composer.json +0 -5
  111. package/test/fixtures/php-app/composer.lock +0 -10
  112. package/test/fixtures/polyglot/cs/packages.lock.json +0 -1
  113. package/test/fixtures/polyglot/js/package-lock.json +0 -1
  114. package/test/fixtures/polyglot/js/package.json +0 -1
  115. package/test/fixtures/polyglot/mvn/pom.xml +0 -7
  116. package/test/fixtures/polyglot/php/composer.lock +0 -1
  117. package/test/fixtures/polyglot/py/poetry.lock +0 -3
  118. package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
  119. package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
  120. package/test/fixtures/private-lib-detection/pom.xml +0 -35
  121. package/test/fixtures/python-pipenv/Pipfile.lock +0 -1
  122. package/test/fixtures/python-poetry/poetry.lock +0 -7
  123. package/test/fixtures/python-reqs/requirements.txt +0 -5
  124. package/test/fixtures/python-uv/uv.lock +0 -3
  125. package/test/fixtures/simple/app/pom.xml +0 -28
  126. package/test/fixtures/simple/lib/pom.xml +0 -18
  127. package/test/fixtures/simple/pom.xml +0 -24
  128. package/test/maven-repo.test.js +0 -111
  129. package/test/maven-version.test.js +0 -57
  130. package/test/monorepo.test.js +0 -132
  131. package/test/npm-registry.test.js +0 -64
  132. package/test/npm.test.js +0 -150
  133. package/test/nuget.test.js +0 -66
  134. package/test/osv.test.js +0 -62
  135. package/test/outdated.test.js +0 -101
  136. package/test/pypi.test.js +0 -72
  137. package/test/snyk.test.js +0 -64
  138. package/test/transitive.test.js +0 -305
  139. package/test/webjar.test.js +0 -33
package/fad-checker.js CHANGED
@@ -16,6 +16,7 @@ const { rimraf } = require("rimraf");
16
16
  const chalk = require("chalk");
17
17
  const pLimit = require("p-limit");
18
18
  const { program } = require("commander");
19
+ const ui = require("./lib/ui");
19
20
 
20
21
  const core = require("./lib/core");
21
22
 
@@ -163,21 +164,39 @@ program
163
164
  .showHelpAfterError()
164
165
  .usage(USAGE)
165
166
  .option("-t, --target <target>", "output directory (will be rm before written). If omitted, the run is read-only.")
166
- .requiredOption("-s, --src <src>", "root directory containing pom.xml files")
167
+ // Not a requiredOption: --import-anonymized scans a descriptor with no source tree.
168
+ .option("-s, --src <src>", "root directory containing pom.xml files")
167
169
  .option("-e, --exclude <exclude>", "regex of groupId to exclude, e.g. '^(client|private)\\.'")
168
170
  .option("-v, --verbose", "verbose")
169
171
  // Defaults: report + transitive + allLibs all ON. Use --no-* to disable.
170
- .option("--no-report", "skip the CVE / EOL / obsolete report")
172
+ .option("--no-report", "write NO output files at all — the scan, terminal summary and --fail-on gate still run (gate-only / CI mode)")
171
173
  .option("--no-transitive", "skip transitive dependency resolution")
172
174
  .option("--no-all-libs", "skip Maven Central queries (outdated check + missing-on-central check)")
173
175
  .option("--no-osv", "skip OSV.dev (Google/GitHub aggregated Maven CVE feed)")
174
176
  .option("--no-nvd", "skip NIST NVD enrichment of matched CVEs")
175
- .option("--offline", "no network: use cached CVE/OSV/NVD/POM data only")
177
+ .option("--no-epss", "skip EPSS (FIRST.org exploit-prediction) enrichment")
178
+ .option("--no-kev", "skip CISA KEV (known-exploited) enrichment")
179
+ // Output family: each --report-<type> takes an OPTIONAL path (omit → default name
180
+ // under --report-output). With NO --report-* flag at all, HTML + .doc are written
181
+ // by default. --no-report writes nothing (scan + gate only).
182
+ .option("--report-html [file]", "write the self-contained HTML report (default: <report-output>/cve-report.html)")
183
+ .option("--report-doc [file]", "write the Word-compatible .doc report (default: <report-output>/cve-report.doc)")
184
+ .option("--report-sbom [file]", "write a CycloneDX 1.6 SBOM, vulnerabilities inline (default: <report-output>/sbom.cdx.json)")
185
+ .option("--report-csaf [file]", "write a CSAF 2.0 VEX document (default: <report-output>/csaf-vex.json)")
186
+ .option("--report-json [file]", "write a flat machine-readable findings JSON (default: <report-output>/findings.json)")
187
+ .option("--report-sarif [file]", "write a SARIF 2.1.0 log for GitHub/GitLab code scanning (default: <report-output>/fad.sarif)")
188
+ .option("--fail-on <level>", "exit non-zero if a production finding meets <level>: low|medium|high|critical|kev|none", "none")
189
+ .option("--ignore <file>", "suppress findings listed in <file> (CVE ids / coords / globs, one per line)")
190
+ .option("--vex <file>", "ingest a CSAF VEX: suppress CVEs marked not_affected/fixed")
191
+ .option("--no-licenses", "skip license detection + copyleft policy check")
192
+ .option("--offline", "no network: use cached CVE/OSV/NVD/EPSS/KEV/POM data only")
176
193
  .option("--set-nvd-key <key>", "save NVD API key to ~/.fad-checker/config.json (10× faster NVD enrichment)")
177
194
  .option("--show-config", "print the persisted ~/.fad-checker/config.json")
178
195
  .option("--export-cache <file>", "tar.gz/zip the ~/.fad-checker/ caches to <file> (excludes config.json by default)")
179
196
  .option("--import-cache <file>", "restore ~/.fad-checker/ from a previously exported archive (existing dir is moved to .bak unless --force)")
180
197
  .option("--include-config", "with --export-cache: also bundle config.json (contains the NVD API key)")
198
+ .option("--export-anonymized <file>", "offline: write an anonymized dependency descriptor (public coordinates only, no paths/URLs) for PASSI audits, then exit")
199
+ .option("--import-anonymized <file>", "online: scan an anonymized descriptor (no --src) to warm the caches; pair with --export-cache for offline reporting")
181
200
  .option("--force", "with --import-cache: replace ~/.fad-checker/ without backup")
182
201
  .option("--report-output <dir>", "report output directory", "./fad-checker-report")
183
202
  .option("--ignore-test", "skip test-scoped dependencies in report")
@@ -187,13 +206,16 @@ program
187
206
  .option("--no-retire", "skip retire.js vendored-JS scan")
188
207
  .option("--retire-refresh", "ignore retire cache and re-scan")
189
208
  .option("--transitive-depth <n>", "max transitive depth", "6")
190
- .option("--ecosystem <list>", "codecs to run: auto|all|<comma list> e.g. maven,npm,nuget,composer,pypi (default: auto = detected)", "auto")
209
+ .option("--ecosystem <list>", "codecs to run: auto|all|<comma list> e.g. maven,npm,nuget,composer,pypi,go,ruby (default: auto = detected)", "auto")
191
210
  .option("--no-maven", "skip the Maven codec")
192
211
  .option("--no-npm", "skip the npm codec")
193
212
  .option("--no-yarn", "skip the Yarn codec")
194
213
  .option("--no-nuget", "skip the NuGet (C#/.NET) codec")
195
214
  .option("--no-composer", "skip the Composer (PHP) codec")
196
215
  .option("--no-pypi", "skip the PyPI (Python) codec")
216
+ .option("--no-go", "skip the Go codec")
217
+ .option("--no-ruby", "skip the Ruby (Bundler) codec")
218
+ .option("--no-jars", "skip scanning embedded .jar/.war/.ear binaries for Maven coordinates")
197
219
  .option("--no-js", "alias: skip JS/npm/yarn manifests even if present (Maven-only)")
198
220
  .option("--repo <url...>", "extra Maven repository URL(s) to try before Maven Central. Supports https://user:pass@host/path/. Repeatable.")
199
221
  .option("--add-repo <name>", "persist a Maven repo: --add-repo <name> <url> [--auth user:pass]")
@@ -205,14 +227,44 @@ program.parse(process.argv);
205
227
  const options = program.opts();
206
228
  const deps2Exclude = options.exclude ? new RegExp(options.exclude) : null;
207
229
  const verbose = !!options.verbose;
230
+
231
+ // Validate --fail-on early: an unrecognised value (typo like "hgih", wrong case)
232
+ // must HARD-FAIL, never silently disable the CI gate.
233
+ if (options.failOn) {
234
+ const FAIL_ON_LEVELS = ["none", "low", "medium", "high", "critical", "kev"];
235
+ const lvl = String(options.failOn).toLowerCase();
236
+ if (!FAIL_ON_LEVELS.includes(lvl)) {
237
+ console.error(chalk.red(`❌ invalid --fail-on "${options.failOn}" — expected one of: ${FAIL_ON_LEVELS.join(", ")}`));
238
+ process.exit(2);
239
+ }
240
+ options.failOn = lvl;
241
+ }
208
242
  // Read-only when no target is given. No need for an explicit --test flag.
209
243
  const readOnly = !options.target;
210
244
 
245
+ // --src is required for every mode except --import-anonymized (which scans a
246
+ // descriptor and has no source tree).
247
+ if (!options.src && !options.importAnonymized) {
248
+ console.error(chalk.red("❌ required option '-s, --src <src>' not specified"));
249
+ process.exit(1);
250
+ }
251
+ if (options.src && options.importAnonymized) {
252
+ console.warn(chalk.yellow("⚠️ --import-anonymized ignores --src (the descriptor is the source of deps)"));
253
+ }
254
+
211
255
  if (options.src && options.target) {
212
- const rel = path.relative(path.resolve(options.src), path.resolve(options.target));
213
- const isSubdir = !rel || (!rel.startsWith("..") && !path.isAbsolute(rel));
214
- if (isSubdir) {
215
- console.error(chalk.red("❌ --target cannot be the same as or a subdirectory of --src"));
256
+ // --target is rimraf'd before being rewritten, so it must NOT overlap --src in
257
+ // EITHER direction: not the same dir, not a subdir of --src, and — the
258
+ // catastrophic case — not a PARENT of --src (which would delete the source tree
259
+ // and everything beside it).
260
+ const srcAbs = path.resolve(options.src);
261
+ const tgtAbs = path.resolve(options.target);
262
+ const relFromSrc = path.relative(srcAbs, tgtAbs); // target as seen from src
263
+ const relToSrc = path.relative(tgtAbs, srcAbs); // src as seen from target
264
+ const targetInsideSrc = !relFromSrc || (!relFromSrc.startsWith("..") && !path.isAbsolute(relFromSrc));
265
+ const srcInsideTarget = !relToSrc || (!relToSrc.startsWith("..") && !path.isAbsolute(relToSrc));
266
+ if (targetInsideSrc || srcInsideTarget) {
267
+ console.error(chalk.red("❌ --target must not overlap --src (it cannot be the same as, a subdirectory of, or a parent of --src) — it is deleted before being rewritten"));
216
268
  process.exit(1);
217
269
  }
218
270
  }
@@ -226,16 +278,16 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
226
278
  try {
227
279
  const hit = await existsInAny(repos, p, { userAgent: "fad-checker-existence" });
228
280
  if (hit) return true;
229
- console.log(`❌ NOT found on any repo: ${g}:${a}`);
281
+ if (verbose) console.log(chalk.dim(` not on any repo: ${g}:${a}`));
230
282
  return false;
231
283
  } catch (err) {
232
- console.info(`error querying repos: ${g}:${a} — ${err.message}`);
284
+ if (verbose) console.info(chalk.dim(` error querying repos: ${g}:${a} — ${err.message}`));
233
285
  return false;
234
286
  }
235
287
  }
236
288
 
237
289
  (async function main() {
238
- console.log(chalk.bold.cyan("\n🚀 Fucking Autonomous Dependency Checker\n") + chalk.gray("─────────────────────────────"));
290
+ ui.banner();
239
291
 
240
292
  // Build the Maven repo list once: persisted repos (from ~/.fad-checker/config.json)
241
293
  // + ad-hoc --repo URLs + Maven Central as final fallback. Used by transitive
@@ -244,18 +296,44 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
244
296
  const { buildRepoList } = require("./lib/maven-repo");
245
297
  const extraRepos = (options.repo || []).map(url => ({ url }));
246
298
  const mavenRepos = buildRepoList(getMavenRepos(), extraRepos);
247
- if (mavenRepos.length > 1) {
248
- console.log(chalk.gray(`📦 Maven repos: ${mavenRepos.map(r => r.name).join(" → ")}`));
249
- }
299
+ const runMode = options.importAnonymized ? "import descriptor" : (options.offline ? "offline" : "online");
300
+ if (options.src) ui.kv("source", chalk.white(options.src));
301
+ if (mavenRepos.length > 1) ui.kv("repos", chalk.white(mavenRepos.map(r => r.name).join(chalk.dim(" → "))));
302
+ ui.kv("mode", chalk.white(runMode));
250
303
 
251
304
  let wrotePom = 0;
252
305
 
306
+ // --- PASSI phase 2: import an anonymized descriptor instead of collecting ---
307
+ // Scans the descriptor's public coordinates online to WARM the coordinate-keyed
308
+ // caches (OSV/NVD/CVE/registry/EOL) + retire signatures. Pair with --export-cache.
309
+ if (options.importAnonymized) {
310
+ const { deserializeDeps } = require("./lib/deps-descriptor");
311
+ let descriptor;
312
+ try { descriptor = JSON.parse(fs.readFileSync(options.importAnonymized, "utf8")); }
313
+ catch (e) { console.error(chalk.red(`❌ could not read --import-anonymized file: ${e.message}`)); process.exit(1); }
314
+ let imported;
315
+ try { imported = deserializeDeps(descriptor); }
316
+ catch (e) { console.error(chalk.red(`❌ invalid descriptor: ${e.message}`)); process.exit(1); }
317
+ const { resolved, activeIds, runMaven, runNpm } = imported;
318
+ ui.section("Anonymized descriptor");
319
+ ui.ok(`imported ${chalk.bold(resolved.size)} dep(s) across ${activeIds.join(", ") || "—"}`);
320
+ if (options.offline) ui.warn("--offline: caches won't warm; only useful to re-render from an already-warm cache");
321
+ if (!resolved.size) { ui.warn("descriptor has no dependencies — nothing to scan"); process.exit(0); }
322
+ // Warm retire signatures (online) so --export-cache carries them for offline JS scanning.
323
+ if (runNpm && !options.offline && options.retire !== false) {
324
+ const { warmRetireSignatures } = require("./lib/retire");
325
+ await warmRetireSignatures({ verbose });
326
+ }
327
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, collectWarnings: [] });
328
+ return;
329
+ }
330
+
253
331
  // --- Codec detection + selection ---
254
332
  const { detectCodecs, allCodecs, getCodec } = require("./lib/codecs");
255
333
  const { resolveActiveCodecs } = require("./lib/codecs/select");
256
334
  const eco = (options.ecosystem || "auto").toLowerCase();
257
335
  const detected = (eco === "auto") ? detectCodecs(options.src).map(c => c.id) : allCodecs().map(c => c.id);
258
- const noCodecs = ["maven", "npm", "yarn", "nuget", "composer", "pypi"].filter(id => options[id] === false);
336
+ const noCodecs = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby"].filter(id => options[id] === false);
259
337
  const activeIds = resolveActiveCodecs(eco, detected, { noCodecs, noJs: !options.js });
260
338
  const runMaven = activeIds.includes("maven");
261
339
  const runNpm = activeIds.includes("npm") || activeIds.includes("yarn");
@@ -269,7 +347,7 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
269
347
  const codec = getCodec(id);
270
348
  let res;
271
349
  try {
272
- res = await codec.collect(options.src, { ignoreTest: !!options.ignoreTest, deps2Exclude, verbose });
350
+ res = await codec.collect(options.src, { ignoreTest: !!options.ignoreTest, deps2Exclude, verbose, scanJars: options.jars !== false, srcRoot: options.src });
273
351
  } catch (err) {
274
352
  console.warn(chalk.red(`❌ ${id} collect failed:`), chalk.dim(err.message));
275
353
  continue;
@@ -279,14 +357,47 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
279
357
  if (id === "maven") mavenCtx = res._maven;
280
358
  }
281
359
 
360
+ // --- Collection summary ---
361
+ ui.section("Collection");
362
+ const ecoCount = {};
363
+ let embeddedCount = 0;
364
+ for (const d of resolved.values()) {
365
+ if (d.provenance === "embedded") { embeddedCount++; continue; } // counted separately below
366
+ ecoCount[d.ecosystem] = (ecoCount[d.ecosystem] || 0) + 1;
367
+ }
368
+ if (runMaven) ui.ok(`${chalk.bold("Maven".padEnd(8))} ${mavenCtx ? mavenCtx.pomFiles.length + " module(s) · " : ""}${ecoCount.maven || 0} direct dep(s)`);
369
+ if (runNpm) ui.ok(`${chalk.bold("npm/yarn".padEnd(8))} ${ecoCount.npm || 0} dep(s)`);
370
+ for (const [id, n] of Object.entries(ecoCount)) {
371
+ if (id === "maven" || id === "npm") continue;
372
+ ui.ok(`${chalk.bold(((getCodec(id)?.label) || id).padEnd(8))} ${n} dep(s)`);
373
+ }
374
+ if (embeddedCount) ui.ok(`${chalk.bold("Embedded".padEnd(8))} ${embeddedCount} coord(s) in .jar/.war/.ear`);
375
+ if (!ecoCount.maven && !ecoCount.npm && !Object.keys(ecoCount).length) ui.warn("no dependencies found in the source tree");
376
+ if (collectWarnings.length) {
377
+ ui.warn(`${collectWarnings.length} manifest warning(s) — best-effort / no lockfile:`);
378
+ for (const w of collectWarnings.slice(0, 5)) ui.info(chalk.dim(w.message));
379
+ if (collectWarnings.length > 5) ui.info(chalk.dim(`…and ${collectWarnings.length - 5} more`));
380
+ }
381
+
382
+ // --- PASSI phase 1: export an anonymized descriptor and exit (no network, no report) ---
383
+ if (options.exportAnonymized) {
384
+ const { serializeDeps } = require("./lib/deps-descriptor");
385
+ const pkgVersion = require("./package.json").version;
386
+ const descriptor = serializeDeps(resolved, { generator: `fad-checker ${pkgVersion}` });
387
+ try { fs.writeFileSync(options.exportAnonymized, JSON.stringify(descriptor, null, 2) + "\n"); }
388
+ catch (e) { console.error(chalk.red(`❌ could not write --export-anonymized file: ${e.message}`)); process.exit(1); }
389
+ const ecoSummary = Object.entries(descriptor.summary.byEcosystem).map(([k, v]) => `${k}:${v}`).join(", ");
390
+ ui.section("Anonymized export");
391
+ ui.ok(`${chalk.bold(descriptor.summary.total)} dep(s) (${ecoSummary || "none"}) → ${chalk.white(options.exportAnonymized)}`);
392
+ ui.info(chalk.dim("public coordinates only — no paths/URLs/host info. Review before transfer."));
393
+ if (!descriptor.summary.total) ui.warn("no dependencies collected — descriptor is empty");
394
+ return;
395
+ }
396
+
282
397
  if (!readOnly) {
283
398
  try { await rimraf(options.target); } catch (_) { /* fresh dir */ }
284
399
  }
285
400
 
286
- if (runMaven && mavenCtx) console.log(chalk.blue(`🔍 Found ${mavenCtx.pomFiles.length} pom.xml files`));
287
- if (runNpm) console.log(chalk.blue(`🔍 JS manifests detected — npm/yarn pipeline enabled`));
288
- console.log();
289
-
290
401
  // Maven POM rewrite (cleanup feature). Parse + inheritance already happened
291
402
  // inside the maven codec's collect(); we reuse its metadata store here.
292
403
  if (runMaven && mavenCtx) {
@@ -296,80 +407,80 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
296
407
  try {
297
408
  if (await core.rewritePoms(pom, store, propsByPom, rewriteOpts)) wrotePom++;
298
409
  } catch (err) {
299
- console.error(chalk.red(`❌ rewrite failed for ${pom}:`), err.message);
410
+ console.error(chalk.red(` rewrite failed for ${pom}:`), err.message);
300
411
  }
301
412
  }
302
413
  }
303
414
 
304
- // ---------- Summary: parents missing / excluded ----------
415
+ // ---------- Maven POM analysis summary (parents missing / excluded) ----------
305
416
  let privateLibIds = [];
306
417
  if (runMaven && mavenCtx) {
307
- const allPomMetadata = mavenCtx.store; // reuse the codec's parsed metadata
308
- console.log(chalk.cyanBright("\n─────────────────────────────────────────────"));
309
- console.log(chalk.cyanBright("📦 Résumé des POM analysés :"));
310
- console.log(chalk.cyanBright("─────────────────────────────────────────────\n"));
311
-
312
- const missingParents = Object.keys(allPomMetadata.missingById)
313
- .filter(id => {
314
- const parts = id.split(":");
315
- if (parts.length === 2) return false;
316
- return !(allPomMetadata.byId[id] || allPomMetadata.byId[`${parts[0]}:${parts[1]}`]);
317
- });
318
-
319
- if (missingParents.length) {
320
- console.log(chalk.yellowBright("⚠️ Parents libs Maven manquants ( si ces lib sont privées snyk plantera ) :"));
321
- console.log(missingParents.map(id => chalk.yellow(" • ") + id).join("\n"));
322
- } else {
323
- console.log(chalk.greenBright("✅ Aucun parent Maven manquant."));
324
- }
325
-
326
- if (options.allLibs) {
327
- const anyMissingLibs = Object.keys(allPomMetadata.anyMissingById)
328
- .filter(id => {
329
- const parts = id.split(":");
330
- if (parts.length === 3) return false;
331
- return !(allPomMetadata.byId[id] || allPomMetadata.byId[`${parts[0]}:${parts[1]}`]);
332
- });
333
- const limit = pLimit(10);
334
- console.log(chalk.magentaBright("\n🚫 Libs absentes de Maven Central :"));
335
- const results = await Promise.all(anyMissingLibs.map(id => {
336
- const [g, a] = id.split(":");
337
- return limit(async () => ({ id, found: await checkMavenLibExist(g, a, mavenRepos) }));
338
- }));
339
- for (const r of results) if (r && r.found === false) privateLibIds.push(r.id);
340
- }
418
+ const allPomMetadata = mavenCtx.store; // reuse the codec's parsed metadata
419
+ ui.section("Maven POM analysis");
341
420
 
342
- if (deps2Exclude) {
343
- const excludedLibs = Object.keys(allPomMetadata.excludedById)
421
+ const missingParents = Object.keys(allPomMetadata.missingById)
344
422
  .filter(id => {
345
423
  const parts = id.split(":");
346
424
  if (parts.length === 2) return false;
347
425
  return !(allPomMetadata.byId[id] || allPomMetadata.byId[`${parts[0]}:${parts[1]}`]);
348
426
  });
349
- if (excludedLibs.length) {
350
- console.log(chalk.magentaBright("\n🚫 Bibliothèques exclues et manquantes :"));
351
- console.log(excludedLibs.map(id => chalk.magenta(" • ") + id).join("\n"));
427
+ if (missingParents.length) {
428
+ ui.warn(`${missingParents.length} missing parent POM(s) Snyk will fail if these are private:`);
429
+ for (const id of missingParents.slice(0, 10)) ui.info(chalk.yellow(id));
430
+ if (missingParents.length > 10) ui.info(chalk.dim(`…and ${missingParents.length - 10} more`));
352
431
  } else {
353
- console.log(chalk.greenBright("\n✅ Aucune bibliothèque exclue et manquante."));
432
+ ui.ok("no missing Maven parent POMs");
354
433
  }
355
- } else {
356
- console.log(chalk.magentaBright("\n🚫 Bibliothèques exclues ( privées ) et manquantes : "));
357
- console.log(chalk.magenta(" • ") + "Pas d'exclusions ( on considère donc que toutes les deps hors parents sont publiques )");
358
- }
359
434
 
360
- console.log(chalk.cyanBright("\n─────────────────────────────────────────────"));
361
- console.log(chalk.cyanBright(`✅ ${wrotePom} POMs nettoyés ont été obtenus`));
362
- if (!readOnly) console.log(chalk.whiteBright(` Ils ont été écrits dans : ${options.target}`));
363
- console.log(chalk.cyanBright("─────────────────────────────────────────────\n"));
364
- } // end runMaven
435
+ if (options.allLibs) {
436
+ const anyMissingLibs = Object.keys(allPomMetadata.anyMissingById)
437
+ .filter(id => {
438
+ const parts = id.split(":");
439
+ if (parts.length === 3) return false;
440
+ return !(allPomMetadata.byId[id] || allPomMetadata.byId[`${parts[0]}:${parts[1]}`]);
441
+ });
442
+ const limit = pLimit(10);
443
+ const results = await Promise.all(anyMissingLibs.map(id => {
444
+ const [g, a] = id.split(":");
445
+ return limit(async () => ({ id, found: await checkMavenLibExist(g, a, mavenRepos) }));
446
+ }));
447
+ for (const r of results) if (r && r.found === false) privateLibIds.push(r.id);
448
+ if (privateLibIds.length) {
449
+ ui.warn(`${privateLibIds.length} lib(s) absent from Maven Central (likely private):`);
450
+ for (const id of privateLibIds.slice(0, 10)) ui.info(chalk.magenta(id));
451
+ if (privateLibIds.length > 10) ui.info(chalk.dim(`…and ${privateLibIds.length - 10} more`));
452
+ }
453
+ }
454
+
455
+ if (deps2Exclude) {
456
+ const excludedLibs = Object.keys(allPomMetadata.excludedById)
457
+ .filter(id => {
458
+ const parts = id.split(":");
459
+ if (parts.length === 2) return false;
460
+ return !(allPomMetadata.byId[id] || allPomMetadata.byId[`${parts[0]}:${parts[1]}`]);
461
+ });
462
+ if (excludedLibs.length) {
463
+ ui.warn(`${excludedLibs.length} excluded-and-missing library(ies):`);
464
+ for (const id of excludedLibs.slice(0, 10)) ui.info(chalk.magenta(id));
465
+ if (excludedLibs.length > 10) ui.info(chalk.dim(`…and ${excludedLibs.length - 10} more`));
466
+ } else {
467
+ ui.ok("no excluded-and-missing libraries");
468
+ }
469
+ }
470
+
471
+ if (!readOnly) ui.ok(`${chalk.bold(wrotePom)} cleaned POM(s) written → ${chalk.white(options.target)}`);
472
+ else ui.info(chalk.dim(`${wrotePom} POM(s) cleanable (read-only — pass -t <dir> to write them)`));
473
+ }
365
474
 
366
- // ---------- Report flow (CVE / EOL / Obsolete) ----------
367
- if (options.report) {
368
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, collectWarnings });
369
- } else if (!readOnly) {
370
- const target = options.target;
371
- console.log(chalk.gray(`💡 Pour lancer Snyk depuis ${target} :`));
372
- console.log(chalk.whiteBright(` cd ${target} && snyk test --json --all-projects | snyk-to-html -o ../snyk-deps-check.html\n`));
475
+ // ---------- Scan flow (CVE / EOL / Obsolete) ----------
476
+ // The scan always runs — it feeds the terminal summary, the file outputs and the
477
+ // CI gate. Which files get written is decided by the --report-* family inside
478
+ // (HTML + .doc by default; --no-report writes nothing).
479
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, collectWarnings });
480
+ if (!readOnly) {
481
+ ui.section("Next step");
482
+ ui.info(`run Snyk on the cleaned tree:`);
483
+ console.log(" " + chalk.white(`cd ${options.target} && snyk test --json --all-projects | snyk-to-html -o ../snyk-deps-check.html`));
373
484
  }
374
485
  })();
375
486
 
@@ -379,31 +490,13 @@ async function runReportFlow(resolved, ecoFlags = {}) {
379
490
  const { writeReports, computeStats } = require("./lib/cve-report");
380
491
  const { getCodec } = require("./lib/codecs");
381
492
  const outdated = require("./lib/outdated");
493
+ const { getNvdApiKey } = require("./lib/config");
382
494
  const offline = !!options.offline;
383
- if (offline) console.log(chalk.gray(" (--offline: cached data only, no network)"));
384
-
385
- console.log(chalk.bold.cyan("\n📋 Rapport CVE / EOL / Obsolète\n") + chalk.gray("─────────────────────────────"));
386
-
387
- // Deps were already collected per-codec by main(). Just report the counts.
388
- const byEcoCount = {};
389
- for (const d of resolved.values()) byEcoCount[d.ecosystem] = (byEcoCount[d.ecosystem] || 0) + 1;
390
- if (runMaven) console.log(chalk.blue(`📚 ${byEcoCount.maven || 0} dépendances Maven directes (incl. parent POMs)`));
391
- if (runNpm) console.log(chalk.blue(`📦 ${byEcoCount.npm || 0} dépendances npm/yarn`));
392
- for (const [ecoId, n] of Object.entries(byEcoCount)) {
393
- if (ecoId === "maven" || ecoId === "npm") continue;
394
- const label = (require("./lib/codecs").getCodec(ecoId)?.label) || ecoId;
395
- console.log(chalk.blue(`📦 ${n} dépendances ${label}`));
396
- }
397
495
 
398
- // Warnings surfaced during collection (e.g. npm no-lockfile fallback).
496
+ // Collection counts already shown in the "Collection" section by main();
497
+ // for --import-anonymized they were shown in the "Anonymized descriptor" section.
399
498
  const npmWarnings = collectWarnings || [];
400
499
  let scanWarnings = [];
401
- if (npmWarnings.length) {
402
- console.log(chalk.yellow(`⚠️ ${npmWarnings.length} manifest warning(s) :`));
403
- for (const w of npmWarnings) {
404
- console.log(chalk.yellow(` • ${w.manifestPath} — ${w.message}`));
405
- }
406
- }
407
500
  const directCount = resolved.size;
408
501
 
409
502
  // Scan-completeness signals: BOMs and unresolved-version deps mean fad-checker
@@ -411,20 +504,39 @@ async function runReportFlow(resolved, ecoFlags = {}) {
411
504
  if (runMaven) {
412
505
  const { detectScanCompletenessWarnings } = require("./lib/scan-completeness");
413
506
  scanWarnings = detectScanCompletenessWarnings(resolved, { ranSnyk: !!options.snyk, ranTransitive: !!options.transitive });
414
- if (scanWarnings.length) {
415
- console.log(chalk.yellow(`\n⚠️ ${scanWarnings.length} scan-completeness alert(s) — a real Maven/Snyk run may surface more findings:`));
416
- for (const w of scanWarnings) {
417
- console.log(chalk.yellow(` • [${w.type}] ${w.message}`));
418
- if (w.items?.length) {
419
- const shown = w.items.slice(0, 6);
420
- for (const it of shown) console.log(chalk.gray(` · ${it}`));
421
- if (w.items.length > shown.length) console.log(chalk.gray(` · …and ${w.items.length - shown.length} more`));
422
- }
423
- }
424
- }
425
507
  }
426
508
 
427
- if (options.transitive && runMaven) {
509
+ // ---- Vulnerability database update (global step progress) ----
510
+ ui.section("Vulnerability database update");
511
+ if (offline) ui.info(chalk.dim("--offline: cached data only, no network"));
512
+
513
+ const hasNvdKey = !!getNvdApiKey();
514
+ if (options.nvd && !offline && !hasNvdKey) {
515
+ ui.warn(chalk.yellow("No NVD API key — enrichment throttled to 5 req/30s (slow)."));
516
+ ui.info(chalk.dim("Free & instant key: https://nvd.nist.gov/developers/request-an-api-key"));
517
+ ui.info(chalk.dim("then: fad-checker --set-nvd-key <KEY>"));
518
+ }
519
+
520
+ // Decide which update steps will run (from flags) so the [n/N] counter is accurate.
521
+ const cveScanner = runMaven ? (getCodec("maven").nativeScanners || []).find(s => s.kind === "cve") : null;
522
+ const cveIndexExists = fs.existsSync(require("./lib/cve-download").CVE_INDEX_PATH);
523
+ const otherRegistryIds = activeIds.filter(id => id !== "maven" && id !== "npm" && id !== "yarn" && getCodec(id)?.checkRegistry);
524
+ const willCve = !!cveScanner && (!(options.cveOffline || offline) || cveIndexExists);
525
+ const willTransitive = !!(options.transitive && runMaven);
526
+ const willOsv = !!options.osv;
527
+ const willOutdated = !!options.allLibs;
528
+ const willNvd = !!options.nvd;
529
+ const willEpss = !!options.epss;
530
+ const willKev = !!options.kev;
531
+ const willLicenses = !!options.licenses;
532
+ const willRetire = !!options.retire;
533
+ // License detection piggybacks on the registry passes (same fetched metadata),
534
+ // 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;
536
+ const progress = new ui.Progress(totalSteps);
537
+
538
+ if (willTransitive) {
539
+ const st = progress.start("Transitive resolution (Maven Central)");
428
540
  await expandWithTransitives(resolved, {
429
541
  verbose,
430
542
  offline,
@@ -432,66 +544,76 @@ async function runReportFlow(resolved, ecoFlags = {}) {
432
544
  includeTestDeps: !options.ignoreTest,
433
545
  repos: mavenRepos,
434
546
  });
435
- console.log(chalk.blue(`🌳 ${resolved.size - directCount} dépendances transitives ajoutées (total: ${resolved.size})`));
547
+ st.done(`+${resolved.size - directCount} transitive (total ${resolved.size})`);
436
548
  }
437
549
 
438
550
  // 1. CVE — native scanner contributed by the maven codec (local cvelistV5 index).
439
551
  let cveMatches = [];
440
552
  let cveDataDate = null;
441
- if (runMaven) {
442
- const sc = (getCodec("maven").nativeScanners || []).find(s => s.kind === "cve");
443
- const indexExists = fs.existsSync(require("./lib/cve-download").CVE_INDEX_PATH);
444
- if (sc && (!(options.cveOffline || offline) || indexExists)) {
445
- try {
446
- const r = await sc.scan(resolved, { cveRefresh: !!options.cveRefresh, cveOffline: !!options.cveOffline, offline, verbose });
447
- cveMatches = r.matches || [];
448
- cveDataDate = r.meta?.cveDataDate || null;
449
- } catch (err) {
450
- console.warn(chalk.yellow("⚠️ CVE scan skipped:"), err.message);
451
- }
553
+ if (willCve) {
554
+ const st = progress.start("CVE index (CVEProject)");
555
+ try {
556
+ const r = await cveScanner.scan(resolved, { cveRefresh: !!options.cveRefresh, cveOffline: !!options.cveOffline, offline, verbose });
557
+ cveMatches = r.matches || [];
558
+ cveDataDate = r.meta?.cveDataDate || null;
559
+ st.done(`${cveMatches.length} match(es)${cveDataDate ? ` · ${String(cveDataDate).slice(0, 10)}` : ""}`);
560
+ } catch (err) {
561
+ st.fail(err.message);
452
562
  }
453
563
  }
454
564
 
455
- // 2. EOL frameworks
565
+ // 2. EOL frameworks (endoflife.date) — always a step.
456
566
  let eolResults = [];
457
- try { eolResults = await outdated.checkEolDeps(resolved, { verbose, offline }); }
458
- catch (err) { console.warn(chalk.yellow("⚠️ EOL check skipped:"), err.message); }
567
+ {
568
+ const st = progress.start("EOL frameworks (endoflife.date)");
569
+ try { eolResults = await outdated.checkEolDeps(resolved, { verbose, offline }); st.done(`${eolResults.length} EOL`); }
570
+ catch (err) { st.fail(err.message); }
571
+ }
572
+
573
+ // License findings accumulate from each registry pass (same fetched metadata)
574
+ // plus Maven's cached POMs — assessed against the copyleft policy below.
575
+ let licenseFindings = [];
459
576
 
460
- // 3. Obsolete / deprecated
577
+ // 3. Obsolete / deprecated — local curated list, instant (no network step).
461
578
  let obsoleteResults = [];
462
579
  try { obsoleteResults = outdated.checkObsoleteDeps(resolved); }
463
- catch (err) { console.warn(chalk.yellow("⚠️ Obsolete check skipped:"), err.message); }
580
+ catch (err) { ui.warn(`obsolete check skipped: ${err.message}`); }
464
581
 
465
- // 4. Outdated (latest Maven Central)
582
+ // 4. Outdated (latest Maven Central) — gated by --all-libs.
466
583
  let outdatedResults = [];
467
- if (options.allLibs) {
468
- try { outdatedResults = await outdated.checkOutdatedDeps(resolved, { verbose, offline, repos: mavenRepos }); }
469
- catch (err) { console.warn(chalk.yellow("⚠️ Outdated check skipped:"), err.message); }
584
+ if (willOutdated) {
585
+ const st = progress.start("Maven Central (outdated)");
586
+ try {
587
+ outdatedResults = await outdated.checkOutdatedDeps(resolved, { verbose, offline, repos: mavenRepos, onProgress: (p, t) => st.tick(p, t) });
588
+ st.done(`${outdatedResults.length} outdated`);
589
+ } catch (err) { st.fail(err.message); }
470
590
  }
471
591
 
472
- // 4a. npm registry — deprecation (always, authoritative maintainer data) and
473
- // outdated (gated by --all-libs like Maven Central). Covers npm deps and
474
- // WebJars (Maven artifacts wrapping npm/bower libs), so it runs even in
475
- // Maven-only mode. One fetch per package; no-ops when there are no targets.
476
- try {
477
- const { checkNpmRegistryDeps } = require("./lib/codecs/npm/registry");
478
- const npmReg = await checkNpmRegistryDeps(resolved, { verbose, offline, allLibs: options.allLibs });
479
- obsoleteResults = obsoleteResults.concat(npmReg.deprecated);
480
- outdatedResults = outdatedResults.concat(npmReg.outdated);
481
- } catch (err) { console.warn(chalk.yellow("⚠️ npm registry check skipped:"), err.message); }
592
+ // 4a. npm registry — deprecation (always, authoritative) + outdated (with --all-libs).
593
+ // Covers npm deps and WebJars, so it runs even in Maven-only mode.
594
+ {
595
+ const st = progress.start("npm registry");
596
+ try {
597
+ 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) });
599
+ obsoleteResults = obsoleteResults.concat(npmReg.deprecated);
600
+ outdatedResults = outdatedResults.concat(npmReg.outdated);
601
+ licenseFindings = licenseFindings.concat(npmReg.licensed || []);
602
+ st.done(`${npmReg.deprecated.length} deprecated, ${npmReg.outdated.length} outdated`);
603
+ } catch (err) { st.fail(err.message); }
604
+ }
482
605
 
483
606
  // 4b. Per-codec registry for ecosystems beyond maven/npm (composer/pypi/nuget).
484
- // maven (Maven Central) + npm (registry) are already covered above; this loop
485
- // drives each remaining active codec's own registry (Packagist abandoned, etc.).
486
- for (const id of activeIds) {
487
- if (id === "maven" || id === "npm" || id === "yarn") continue;
607
+ for (const id of otherRegistryIds) {
488
608
  const codec = getCodec(id);
489
- if (!codec?.checkRegistry) continue;
609
+ const st = progress.start(`${codec.label || id} registry`);
490
610
  try {
491
- const reg = await codec.checkRegistry(resolved, { verbose, offline, allLibs: options.allLibs });
611
+ const reg = await codec.checkRegistry(resolved, { verbose, offline, allLibs: options.allLibs, onProgress: (p, t) => st.tick(p, t) });
492
612
  obsoleteResults = obsoleteResults.concat(reg.deprecated || []);
493
613
  outdatedResults = outdatedResults.concat(reg.outdated || []);
494
- } catch (err) { console.warn(chalk.yellow(`⚠️ ${id} registry check skipped:`), err.message); }
614
+ licenseFindings = licenseFindings.concat(reg.licensed || []);
615
+ st.done(`${(reg.deprecated || []).length} deprecated, ${(reg.outdated || []).length} outdated`);
616
+ } catch (err) { st.fail(err.message); }
495
617
  }
496
618
 
497
619
  // Cross-section dedup: drop entries from outdated that already appear in EOL/Obsolete
@@ -503,41 +625,76 @@ async function runReportFlow(resolved, ecoFlags = {}) {
503
625
  });
504
626
 
505
627
  // 4b. OSV.dev — Maven-native CVE+GHSA feed (huge recall win over raw CVEProject)
506
- if (options.osv) {
628
+ if (willOsv) {
629
+ const st = progress.start("OSV.dev");
507
630
  try {
508
631
  const { queryOsvForDeps } = require("./lib/osv");
509
- const osvMatches = await queryOsvForDeps(resolved, { verbose, offline });
632
+ const osvMatches = await queryOsvForDeps(resolved, { verbose, offline, onProgress: (p, t) => st.tick(p, t) });
510
633
  const before = cveMatches.length;
511
634
  cveMatches = mergeBySource(cveMatches, osvMatches);
512
- console.log(chalk.blue(`🌐 OSV.dev: ${osvMatches.length} vulnerabilities, +${cveMatches.length - before} new after merge`));
635
+ st.done(`${osvMatches.length} vulns · +${cveMatches.length - before} after merge`);
513
636
  } catch (err) {
514
- console.warn(chalk.yellow("⚠️ OSV.dev skipped:"), err.message);
637
+ st.fail(err.message);
515
638
  }
516
639
  }
517
640
 
518
- // 4c. NVD enrichment — canonical description + full CVSS for matched CVEs
519
- if (options.nvd && cveMatches.length) {
520
- try {
521
- const { enrichMatches } = require("./lib/nvd");
522
- await enrichMatches(cveMatches, { verbose, offline });
523
- } catch (err) {
524
- console.warn(chalk.yellow("⚠️ NVD enrichment skipped:"), err.message);
641
+ // 4c. NVD enrichment — canonical description + full CVSS for matched CVEs.
642
+ if (willNvd) {
643
+ const st = progress.start("NVD enrichment");
644
+ if (!cveMatches.length) {
645
+ st.skip("no CVE to enrich");
646
+ } else {
647
+ try {
648
+ const { enrichMatches } = require("./lib/nvd");
649
+ await enrichMatches(cveMatches, { verbose, offline, onProgress: (p, t) => st.tick(p, t) });
650
+ // 4d. CPE refinement — use NVD's CPE configurations to upgrade match
651
+ // confidence and flag likely false positives (version outside CPE range).
652
+ let filtered = 0;
653
+ try {
654
+ const { refineMatchesWithCpe } = require("./lib/cpe");
655
+ refineMatchesWithCpe(cveMatches);
656
+ filtered = cveMatches.filter(m => m.cpeFiltered).length;
657
+ } catch (err) { ui.warn(`CPE refinement skipped: ${err.message}`); }
658
+ const uniqueCves = new Set(cveMatches.map(m => m.cve?.id)).size;
659
+ st.done(`${uniqueCves} CVE${filtered ? ` · ${filtered} false-positive(s) filtered` : ""}${hasNvdKey ? "" : " · no key (slow)"}`);
660
+ } catch (err) { st.fail(err.message); }
525
661
  }
662
+ }
526
663
 
527
- // 4d. CPE refinement use NVD's CPE configurations to upgrade match
528
- // confidence and flag likely false positives (CVE matched a product
529
- // name but the dep version is outside any vulnerable CPE range).
530
- try {
531
- const { refineMatchesWithCpe } = require("./lib/cpe");
532
- refineMatchesWithCpe(cveMatches);
533
- const upgraded = cveMatches.filter(m => m.cpeConfidence).length;
534
- const filtered = cveMatches.filter(m => m.cpeFiltered).length;
535
- if (verbose) console.log(chalk.gray(` CPE: ${upgraded} matches with CPE confirmation, ${filtered} flagged as likely false positives`));
536
- } catch (err) {
537
- console.warn(chalk.yellow("⚠️ CPE refinement skipped:"), err.message);
664
+ // 4e. EPSSexploit-prediction percentile for each matched CVE (FIRST.org).
665
+ if (willEpss) {
666
+ const st = progress.start("EPSS (FIRST.org)");
667
+ if (!cveMatches.length) { st.skip("no CVE"); }
668
+ else {
669
+ try {
670
+ const { enrichEpss } = require("./lib/epss");
671
+ await enrichEpss(cveMatches, { verbose, offline, onProgress: (p, t) => st.tick(p, t) });
672
+ const scored = cveMatches.filter(m => m.cve?.epssPercentile != null).length;
673
+ st.done(`${scored} scored`);
674
+ } catch (err) { st.fail(err.message); }
675
+ }
676
+ }
677
+
678
+ // 4f. CISA KEV — flag CVEs known to be exploited in the wild.
679
+ if (willKev) {
680
+ const st = progress.start("CISA KEV");
681
+ if (!cveMatches.length) { st.skip("no CVE"); }
682
+ else {
683
+ try {
684
+ const { enrichKev } = require("./lib/kev");
685
+ await enrichKev(cveMatches, { verbose, offline });
686
+ const kevd = cveMatches.filter(m => m.cve?.kev).length;
687
+ st.done(`${kevd} known-exploited`);
688
+ } catch (err) { st.fail(err.message); }
538
689
  }
539
690
  }
540
691
 
692
+ // 4g. Composite priority (KEV > EPSS-weighted CVSS). Always — cheap, pure.
693
+ try {
694
+ const { attachPriority } = require("./lib/priority");
695
+ attachPriority(cveMatches);
696
+ } catch (err) { ui.warn(`priority scoring skipped: ${err.message}`); }
697
+
541
698
  // 5. retire.js — native "vendored" scanner contributed by the npm codec. Scans
542
699
  // vendored JS files (jquery copies, bootstrap, pdf.js, …) that live in the
543
700
  // source tree without any lockfile to back them.
@@ -545,16 +702,17 @@ async function runReportFlow(resolved, ecoFlags = {}) {
545
702
  // vendored .js (which can live in a Maven project's resources too). The
546
703
  // scanner is owned by the npm codec but runs whenever --retire is on.
547
704
  let retireMatches = [];
548
- if (options.retire) {
705
+ if (willRetire) {
706
+ const st = progress.start("retire.js (vendored JS)");
549
707
  const sc = (getCodec("npm").nativeScanners || []).find(s => s.kind === "vendored");
550
- if (sc) {
708
+ if (!sc) { st.skip("scanner unavailable"); }
709
+ else if (!options.src) { st.skip("no source tree (descriptor import)"); }
710
+ else {
551
711
  try {
552
712
  const r = await sc.scan(resolved, { src: options.src, verbose, retireRefresh: !!options.retireRefresh, offline });
553
713
  retireMatches = r.matches || [];
554
- console.log(chalk.blue(`🔎 retire.js: ${retireMatches.length} vendored-JS finding(s)`));
555
- } catch (err) {
556
- console.warn(chalk.yellow("⚠️ retire.js skipped:"), err.message);
557
- }
714
+ st.done(`${retireMatches.length} finding(s)`);
715
+ } catch (err) { st.fail(err.message); }
558
716
  }
559
717
  }
560
718
 
@@ -562,16 +720,16 @@ async function runReportFlow(resolved, ecoFlags = {}) {
562
720
  let snykMatches = [];
563
721
  if (options.snyk) {
564
722
  if (!options.target) {
565
- console.warn(chalk.yellow("⚠️ --snyk requires --target (snyk runs on cleaned POMs)"));
723
+ ui.warn("--snyk requires --target (snyk runs on cleaned POMs)");
566
724
  } else {
567
725
  const snyk = require("./lib/snyk");
568
726
  try {
569
727
  const raw = await snyk.runSnykTest(options.target, { verbose });
570
728
  snykMatches = snyk.parseSnykResults(raw);
571
729
  cveMatches = snyk.mergeWithFadResults(cveMatches, snykMatches);
572
- console.log(chalk.blue(`🐍 Snyk: ${snykMatches.length} findings merged`));
730
+ ui.ok(`Snyk: ${snykMatches.length} findings merged`);
573
731
  } catch (err) {
574
- console.warn(chalk.yellow("⚠️ Snyk run failed:"), err.message);
732
+ ui.warn(`Snyk run failed: ${err.message}`);
575
733
  }
576
734
  }
577
735
  }
@@ -581,90 +739,241 @@ async function runReportFlow(resolved, ecoFlags = {}) {
581
739
  // full per-bucket list (including cpeFiltered) so the HTML report can render
582
740
  // its "Likely false positives" appendix — only the CLI headline excludes
583
741
  // cpeFiltered to avoid alarming on triaged-out matches.
584
- const prodMatches = cveMatches.filter(m => !m.dep?.isDev);
585
- const devMatches = cveMatches.filter(m => m.dep?.isDev);
586
- const prodActive = prodMatches.filter(m => !m.cpeFiltered);
587
- const devActive = devMatches.filter(m => !m.cpeFiltered);
742
+ // Triage suppress accepted-risk / false-positive findings (--ignore / --vex).
743
+ // Marked in place; kept in the machine exports (flagged) but dropped from the
744
+ // human report's active chapters and from CI gating.
745
+ let suppressedCount = 0;
746
+ if (options.ignore || options.vex) {
747
+ try {
748
+ const { parseIgnoreFile, parseVex, applySuppressions } = require("./lib/suppress");
749
+ const rules = [];
750
+ if (options.ignore) rules.push(...parseIgnoreFile(fs.readFileSync(options.ignore, "utf8")));
751
+ if (options.vex) rules.push(...parseVex(JSON.parse(fs.readFileSync(options.vex, "utf8"))));
752
+ suppressedCount = applySuppressions(cveMatches, rules);
753
+ const via = [options.ignore && "--ignore", options.vex && "--vex"].filter(Boolean).join(" + ");
754
+ if (suppressedCount) ui.info(chalk.dim(`triage: ${suppressedCount} finding(s) suppressed by ${via}`));
755
+ } catch (err) { ui.warn(`suppression skipped: ${err.message}`); }
756
+ }
757
+
758
+ const { sortByPriority } = require("./lib/priority");
759
+ const isEmbedded = m => m.dep?.provenance === "embedded";
760
+ // Embedded-binary findings get their own chapter, so keep them out of the
761
+ // declared prod/dev sets (a coord that's both declared AND embedded yields two
762
+ // distinct records — one in each — which is the intended, audit-useful split).
763
+ const prodMatches = cveMatches.filter(m => !m.dep?.isDev && !m.suppressed && !isEmbedded(m));
764
+ const devMatches = cveMatches.filter(m => m.dep?.isDev && !m.suppressed && !isEmbedded(m));
765
+ const embeddedMatches = cveMatches.filter(m => isEmbedded(m) && !m.suppressed);
766
+ const prodActive = sortByPriority(prodMatches.filter(m => !m.cpeFiltered));
767
+ const devActive = sortByPriority(devMatches.filter(m => !m.cpeFiltered));
768
+ const embeddedActive = sortByPriority(embeddedMatches.filter(m => !m.cpeFiltered));
769
+ const kevCount = prodActive.filter(m => m.cve?.kev).length;
588
770
  const cpeFilteredCount = (prodMatches.length - prodActive.length) + (devMatches.length - devActive.length);
589
771
 
590
772
  const stats = computeStats(prodActive);
591
773
  const devStats = computeStats(devActive);
592
- console.log(chalk.bold.cyan(`\n 1. CVE Vulnerabilities (production: ${prodActive.length})`));
593
- console.log(` critical=${stats.critical} high=${stats.high} medium=${stats.medium} low=${stats.low} unknown=${stats.unknown}`);
774
+ const sev = ui.sevColor;
594
775
  const depLabel = d => d.ecosystem === "npm" ? `npm:${d.artifactId}` : `${d.groupId}:${d.artifactId}`;
595
- for (const m of prodActive.slice(0, 20)) {
596
- const sev = (m.cve.severity || "UNKNOWN").padEnd(8);
597
- console.log(` ${chalk.red(sev)} ${m.cve.id} ${depLabel(m.dep)}:${m.dep.version}`);
776
+ const coordOf = depLabel; // npm deps show as "npm:name", others as "g:a"
777
+ const fmtStats = s => [
778
+ s.critical ? sev("CRITICAL")(`${s.critical} critical`) : null,
779
+ s.high ? sev("HIGH")(`${s.high} high`) : null,
780
+ s.medium ? sev("MEDIUM")(`${s.medium} medium`) : null,
781
+ s.low ? sev("LOW")(`${s.low} low`) : null,
782
+ s.unknown ? chalk.gray(`${s.unknown} unknown`) : null,
783
+ ].filter(Boolean).join(" ") || chalk.gray("none");
784
+ const heading = (label, n, extra = "") => console.log("\n " + chalk.bold(label) + chalk.dim(` (${n})`) + (extra ? " " + extra : ""));
785
+
786
+ ui.section("Results");
787
+
788
+ heading("CVE · production", prodActive.length, fmtStats(stats) + (kevCount ? " " + chalk.bgRed.white(` ${kevCount} KEV `) : ""));
789
+ for (const m of prodActive.slice(0, 12)) {
790
+ const epss = m.cve?.epssPercentile != null ? chalk.dim(` epss ${Math.round(m.cve.epssPercentile * 100)}%`) : "";
791
+ const kev = m.cve?.kev ? " " + chalk.bgRed.white(" KEV ") : "";
792
+ console.log(" " + sev(m.cve.severity)((m.cve.severity || "UNKNOWN").padEnd(8)) + " " + chalk.white(m.cve.id) + " " + chalk.dim(`${depLabel(m.dep)}:${m.dep.version}`) + epss + kev);
598
793
  }
599
- if (prodActive.length > 20) console.log(` ... and ${prodActive.length - 20} more (see report)`);
600
- if (cpeFilteredCount) console.log(chalk.gray(` (${cpeFilteredCount} likely false positives moved to report appendix)`));
794
+ if (prodActive.length > 12) console.log(chalk.dim(`and ${prodActive.length - 12} more (see report)`));
795
+ if (cpeFilteredCount) console.log(chalk.dim(` ${cpeFilteredCount} likely false positive(s) report appendix`));
601
796
 
602
- if (devActive.length) {
603
- console.log(chalk.bold.cyan(`\n 2. CVE in dev dependencies (${devActive.length})`));
604
- console.log(` critical=${devStats.critical} high=${devStats.high} medium=${devStats.medium} low=${devStats.low} unknown=${devStats.unknown}`);
605
- }
606
- if (retireMatches.length) {
607
- console.log(chalk.bold.cyan(`\n 3. Vendored JS (retire.js): ${retireMatches.length}`));
608
- for (const m of retireMatches.slice(0, 10)) {
609
- console.log(` ${chalk.red((m.cve.severity || "?").padEnd(8))} ${m.cve.id} ${m.dep.artifactId}@${m.dep.version} ← ${m.dep.vendoredFile}`);
797
+ heading("CVE · dev", devActive.length, devActive.length ? fmtStats(devStats) : "");
798
+
799
+ if (embeddedActive.length) {
800
+ heading("CVE · embedded binaries", embeddedActive.length, fmtStats(computeStats(embeddedActive)));
801
+ for (const m of embeddedActive.slice(0, 8)) {
802
+ const top = (m.dep.manifestPaths?.[0] || "").split("!/")[0];
803
+ console.log(" " + sev(m.cve.severity)((m.cve.severity || "UNKNOWN").padEnd(8)) + " " + chalk.white(m.cve.id) + " " + chalk.dim(`${depLabel(m.dep)}:${m.dep.version}`) + chalk.dim(` ⊂ ${top}`));
610
804
  }
611
- if (retireMatches.length > 10) console.log(` ... and ${retireMatches.length - 10} more (see report)`);
805
+ if (embeddedActive.length > 8) console.log(chalk.dim(`and ${embeddedActive.length - 8} more (see report ch.1B)`));
612
806
  }
613
807
 
614
- // npm deps have no groupId; show them as "npm:name" rather than ":name".
615
- const coordOf = d => d.ecosystem === "npm" ? `npm:${d.artifactId}` : `${d.groupId}:${d.artifactId}`;
808
+ 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)));
810
+ if (eolResults.length > 8) console.log(chalk.dim(` …and ${eolResults.length - 8} more`));
811
+
812
+ 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")));
814
+ if (obsoleteResults.length > 8) console.log(chalk.dim(` …and ${obsoleteResults.length - 8} more`));
815
+
816
+ 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)}`);
818
+ if (outdatedResults.length > 8) console.log(chalk.dim(` …and ${outdatedResults.length - 8} more`));
616
819
 
617
- console.log(chalk.bold.cyan("\n 2. End-of-Life Frameworks"));
618
- for (const e of eolResults) console.log(` ${e.product.padEnd(20)} ${coordOf(e.dep)}:${e.dep.version} ${e.eol}`);
619
- if (!eolResults.length) console.log(chalk.gray(" (none)"));
820
+ if (retireMatches.length) {
821
+ heading("Vendored JS (retire.js)", retireMatches.length);
822
+ for (const m of retireMatches.slice(0, 8)) console.log(" " + sev(m.cve.severity)((m.cve.severity || "?").padEnd(8)) + " " + chalk.white(m.cve.id) + " " + chalk.dim(`${m.dep.artifactId}@${m.dep.version}`));
823
+ if (retireMatches.length > 8) console.log(chalk.dim(` …and ${retireMatches.length - 8} more`));
824
+ }
620
825
 
621
- console.log(chalk.bold.cyan("\n 3. Obsolete / Deprecated Libraries"));
622
- for (const o of obsoleteResults) console.log(` ${(o.severity || "info").padEnd(8)} ${coordOf(o.dep)}:${o.dep.version} → ${o.replacement || "n/a"}`);
623
- if (!obsoleteResults.length) console.log(chalk.gray(" (none)"));
826
+ if (scanWarnings.length) {
827
+ console.log();
828
+ ui.warn(`${scanWarnings.length} scan-completeness note(s) — a real Maven/Snyk run may surface more:`);
829
+ for (const w of scanWarnings) {
830
+ ui.info(chalk.dim(`[${w.type}] ${w.message}`));
831
+ for (const it of (w.items || []).slice(0, 4)) console.log(" " + chalk.dim(`· ${it}`));
832
+ if ((w.items || []).length > 4) console.log(" " + chalk.dim(`· …and ${w.items.length - 4} more`));
833
+ }
834
+ }
624
835
 
625
- console.log(chalk.bold.cyan("\n 4. Outdated Libraries"));
626
- for (const o of outdatedResults.slice(0, 20)) console.log(` ${coordOf(o.dep)} ${o.dep.version} ${o.latest}`);
627
- if (outdatedResults.length > 20) console.log(` ... and ${outdatedResults.length - 20} more`);
628
- if (!outdatedResults.length && options.allLibs) console.log(chalk.gray(" (none)"));
629
- if (!options.allLibs) console.log(chalk.gray(" (re-run with -a/--allLibs to query Maven Central)"));
836
+ // License assessment — Maven licenses come (network-free) from cached POMs;
837
+ // the registry passes already filled licenseFindings for the other ecosystems.
838
+ let licenseResults = null;
839
+ if (willLicenses) {
840
+ try {
841
+ if (runMaven) {
842
+ const { collectMavenLicenses } = require("./lib/maven-license");
843
+ licenseFindings = licenseFindings.concat(collectMavenLicenses(resolved));
844
+ }
845
+ const { assessLicenses } = require("./lib/license-policy");
846
+ licenseResults = assessLicenses(licenseFindings);
847
+ const flaggedN = licenseResults.flagged.length;
848
+ heading("Licenses", licenseResults.assessed.length, flaggedN ? chalk.yellow(`${flaggedN} to review`) : "");
849
+ for (const e of licenseResults.flagged.slice(0, 8)) {
850
+ console.log(" " + chalk.yellow((e.category).padEnd(16)) + " " + chalk.dim(`${coordOf(e.dep)}`) + " " + chalk.dim((e.ids.concat(e.raw)).join(", ") || "—"));
851
+ }
852
+ if (licenseResults.flagged.length > 8) console.log(chalk.dim(` …and ${licenseResults.flagged.length - 8} more`));
853
+ } catch (err) { ui.warn(`license assessment skipped: ${err.message}`); }
854
+ }
630
855
 
631
856
  const reportDir = options.reportOutput || "./fad-checker-report";
632
- await fs.promises.mkdir(reportDir, { recursive: true });
857
+ // --import-anonymized has no source tree; keep the report path-free (consistent
858
+ // with the anonymized descriptor it was fed).
859
+ const srcResolved = options.src ? path.resolve(options.src) : null;
633
860
  const projectInfo = {
634
- name: path.basename(path.resolve(options.src)),
635
- src: path.resolve(options.src),
861
+ name: srcResolved ? path.basename(srcResolved) : "anonymized-descriptor",
862
+ src: srcResolved || "(anonymized descriptor — source path withheld)",
636
863
  generatedAt: new Date().toISOString(),
637
864
  toolVersion: pkg.version,
638
865
  cveDataDate,
639
866
  };
640
- const { htmlPath, docPath } = await writeReports({
641
- cveMatches: prodMatches,
642
- devCveMatches: devMatches,
643
- retireMatches,
644
- eolResults,
645
- obsoleteResults,
646
- outdatedResults,
647
- resolvedDeps: resolved,
648
- projectInfo,
649
- outputDir: reportDir,
650
- warnings: [
651
- ...npmWarnings,
652
- ...scanWarnings,
653
- ...(privateLibIds.length ? [{
654
- type: "private-libs",
655
- count: privateLibIds.length,
656
- // Enrich each private lib with the relative path(s) of the pom(s)
657
- // that declare it, so the team knows where to look.
658
- items: privateLibIds.map(id => {
659
- const dep = resolved.get(id);
660
- const paths = (dep?.pomPaths || []).map(p => path.relative(options.src, p));
661
- return { id, manifestPaths: paths };
662
- }),
663
- message: `${privateLibIds.length} Maven coord(s) not found on Maven Central — they are private/internal libraries. Their CVEs (if any) cannot be detected by fad-checker; if you have an internal CVE feed, audit them separately.`,
664
- }] : []),
665
- ],
666
- });
667
- console.log(chalk.bold.green(`\n✅ Report written:\n ${htmlPath}\n ${docPath}\n`));
867
+
868
+ // --- Output target resolution -------------------------------------------------
869
+ // One --report-<type> flag per output, each taking an OPTIONAL path: a string is
870
+ // an explicit path, `true` means "use the default name under --report-output",
871
+ // undefined means "not requested". If NO --report-* flag is given at all, fall
872
+ // back to the historical default set (HTML + .doc). --no-report suppresses ALL
873
+ // file outputs (the scan, terminal summary and --fail-on gate still ran).
874
+ const DEFAULT_NAMES = { html: "cve-report.html", doc: "cve-report.doc", sbom: "sbom.cdx.json", csaf: "csaf-vex.json", json: "findings.json", sarif: "fad.sarif" };
875
+ const sel = { html: options.reportHtml, doc: options.reportDoc, sbom: options.reportSbom, csaf: options.reportCsaf, json: options.reportJson, sarif: options.reportSarif };
876
+ const anySpecified = Object.values(sel).some(v => v !== undefined);
877
+ const resolveOut = key => {
878
+ const v = sel[key];
879
+ if (v === undefined) return (!anySpecified && (key === "html" || key === "doc")) ? path.join(reportDir, DEFAULT_NAMES[key]) : null;
880
+ return (v === true) ? path.join(reportDir, DEFAULT_NAMES[key]) : v;
881
+ };
882
+ const out = options.report === false
883
+ ? { html: null, doc: null, sbom: null, csaf: null, json: null, sarif: null }
884
+ : { html: resolveOut("html"), doc: resolveOut("doc"), sbom: resolveOut("sbom"), csaf: resolveOut("csaf"), json: resolveOut("json"), sarif: resolveOut("sarif") };
885
+ const ensureDir = async p => { if (p) await fs.promises.mkdir(path.dirname(path.resolve(p)), { recursive: true }); };
886
+
887
+ const reportWarnings = [
888
+ ...(suppressedCount ? [{
889
+ type: "suppressed",
890
+ count: suppressedCount,
891
+ message: `${suppressedCount} finding(s) suppressed via triage (--ignore/--vex) — excluded from the chapters above and from CI gating, but retained (flagged) in the JSON/SBOM/CSAF exports.`,
892
+ }] : []),
893
+ ...npmWarnings,
894
+ ...scanWarnings,
895
+ ...(privateLibIds.length ? [{
896
+ type: "private-libs",
897
+ count: privateLibIds.length,
898
+ items: privateLibIds.map(id => {
899
+ const dep = resolved.get(id);
900
+ const paths = (dep?.pomPaths || []).map(p => path.relative(options.src, p));
901
+ return { id, manifestPaths: paths };
902
+ }),
903
+ message: `${privateLibIds.length} Maven coord(s) not found on Maven Central — they are private/internal libraries. Their CVEs (if any) cannot be detected by fad-checker; if you have an internal CVE feed, audit them separately.`,
904
+ }] : []),
905
+ ];
906
+
907
+ const wrote = [];
908
+ if (out.html || out.doc) {
909
+ await ensureDir(out.html); await ensureDir(out.doc);
910
+ const { htmlPath, docPath } = await writeReports({
911
+ cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches,
912
+ eolResults, obsoleteResults, outdatedResults, licenseResults,
913
+ resolvedDeps: resolved, projectInfo, warnings: reportWarnings,
914
+ htmlPath: out.html, docPath: out.doc,
915
+ });
916
+ if (htmlPath) wrote.push(["HTML report", htmlPath]);
917
+ if (docPath) wrote.push(["Word .doc", docPath]);
918
+ }
919
+
920
+ // Machine-readable exports. Use the full match set (prod + dev + cpe-filtered) so
921
+ // the artifacts are complete; cpeFiltered is marked as a property/flag, not dropped.
922
+ if (out.sbom) {
923
+ try {
924
+ const { writeCycloneDx } = require("./lib/sbom-export");
925
+ await ensureDir(out.sbom);
926
+ writeCycloneDx(resolved, cveMatches, out.sbom, { projectInfo, toolVersion: pkg.version, timestamp: projectInfo.generatedAt, licenseResults });
927
+ wrote.push(["CycloneDX SBOM", out.sbom]);
928
+ } catch (err) { ui.warn(`SBOM export failed: ${err.message}`); }
929
+ }
930
+ if (out.csaf) {
931
+ try {
932
+ const { writeCsaf } = require("./lib/csaf-export");
933
+ await ensureDir(out.csaf);
934
+ writeCsaf(resolved, cveMatches, out.csaf, { projectInfo, toolVersion: pkg.version, timestamp: projectInfo.generatedAt });
935
+ wrote.push(["CSAF 2.0 VEX", out.csaf]);
936
+ } catch (err) { ui.warn(`CSAF export failed: ${err.message}`); }
937
+ }
938
+ if (out.json) {
939
+ try {
940
+ const { writeFindings } = require("./lib/json-export");
941
+ await ensureDir(out.json);
942
+ writeFindings({ cveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version }, out.json);
943
+ wrote.push(["Findings JSON", out.json]);
944
+ } catch (err) { ui.warn(`JSON export failed: ${err.message}`); }
945
+ }
946
+ if (out.sarif) {
947
+ try {
948
+ const { writeSarif } = require("./lib/sarif-export");
949
+ await ensureDir(out.sarif);
950
+ writeSarif(cveMatches.filter(m => !m.suppressed), out.sarif, { projectInfo, toolVersion: pkg.version });
951
+ wrote.push(["SARIF", out.sarif]);
952
+ } catch (err) { ui.warn(`SARIF export failed: ${err.message}`); }
953
+ }
954
+
955
+ if (wrote.length) {
956
+ ui.section("Output");
957
+ for (const [label, p] of wrote) ui.ok(`${label} → ${chalk.white(p)}`);
958
+ } else if (options.report === false) {
959
+ ui.info(chalk.dim("--no-report: no files written (scan + gate only)"));
960
+ }
961
+ console.log();
962
+
963
+ // CI gating — set a non-zero exit code (after all reports/exports are written)
964
+ // when a production finding meets the --fail-on threshold.
965
+ if (options.failOn && options.failOn !== "none") {
966
+ const { evaluateGate } = require("./lib/gate");
967
+ // Embedded-binary findings are real production risk → gate on them too.
968
+ const gate = evaluateGate([...prodActive, ...embeddedActive], options.failOn);
969
+ if (gate.failed) {
970
+ ui.section("Gate");
971
+ console.log(chalk.red(`✗ --fail-on ${options.failOn}: ${gate.reason}`));
972
+ process.exitCode = 1;
973
+ } else if (verbose) {
974
+ ui.info(chalk.dim(`--fail-on ${options.failOn}: no blocking finding`));
975
+ }
976
+ }
668
977
  }
669
978
 
670
979
  /**
@@ -674,7 +983,9 @@ async function runReportFlow(resolved, ecoFlags = {}) {
674
983
  */
675
984
  function mergeBySource(existing, additions) {
676
985
  const byKey = new Map();
677
- const k = m => `${m.dep.groupId}:${m.dep.artifactId}:${m.dep.version}|${m.cve.id}`;
986
+ // coordKey keeps embedded-binary findings distinct from a same-g:a:v declared dep
987
+ // (see cve-match dedup). Falls back to g:a for any match lacking a coordKey.
988
+ const k = m => `${m.dep.coordKey || (m.dep.groupId + ":" + m.dep.artifactId)}:${m.dep.version}|${m.cve.id}`;
678
989
  for (const m of existing || []) byKey.set(k(m), { ...m, source: m.source || "fad" });
679
990
  for (const m of additions || []) {
680
991
  const key = k(m);