fad-checker 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +91 -17
  3. package/completions/fad-checker.bash +1 -1
  4. package/completions/fad-checker.zsh +14 -1
  5. package/data/license-policy.json +97 -0
  6. package/fad-checker-report/cve-report.doc +630 -0
  7. package/fad-checker-report/cve-report.html +748 -0
  8. package/fad-checker.js +278 -53
  9. package/lib/cache-archive.js +3 -0
  10. package/lib/codecs/codec.interface.js +3 -0
  11. package/lib/codecs/composer/parse.js +3 -0
  12. package/lib/codecs/composer/registry.js +13 -5
  13. package/lib/codecs/composer.codec.js +3 -0
  14. package/lib/codecs/go/parse.js +65 -0
  15. package/lib/codecs/go/registry.js +74 -0
  16. package/lib/codecs/go.codec.js +76 -0
  17. package/lib/codecs/index.js +7 -2
  18. package/lib/codecs/maven/jar-scan.js +199 -0
  19. package/lib/codecs/maven.codec.js +17 -1
  20. package/lib/codecs/npm/collect.js +11 -0
  21. package/lib/codecs/npm/parse.js +3 -0
  22. package/lib/codecs/npm/registry.js +10 -2
  23. package/lib/codecs/npm.codec.js +3 -0
  24. package/lib/codecs/nuget/parse.js +3 -0
  25. package/lib/codecs/nuget/registry.js +11 -4
  26. package/lib/codecs/nuget.codec.js +3 -0
  27. package/lib/codecs/pypi/parse.js +7 -2
  28. package/lib/codecs/pypi/registry.js +24 -5
  29. package/lib/codecs/pypi.codec.js +3 -0
  30. package/lib/codecs/recipes.js +28 -1
  31. package/lib/codecs/ruby/parse.js +42 -0
  32. package/lib/codecs/ruby/registry.js +76 -0
  33. package/lib/codecs/ruby.codec.js +66 -0
  34. package/lib/codecs/select.js +3 -0
  35. package/lib/codecs/yarn.codec.js +3 -0
  36. package/lib/config.js +3 -0
  37. package/lib/core.js +3 -0
  38. package/lib/cpe.js +30 -5
  39. package/lib/csaf-export.js +159 -0
  40. package/lib/cve-download.js +3 -0
  41. package/lib/cve-match.js +12 -1
  42. package/lib/cve-report.js +157 -28
  43. package/lib/dep-record.js +15 -2
  44. package/lib/deps-descriptor.js +3 -0
  45. package/lib/epss.js +115 -0
  46. package/lib/gate.js +45 -0
  47. package/lib/json-export.js +110 -0
  48. package/lib/kev.js +88 -0
  49. package/lib/license-policy.js +110 -0
  50. package/lib/maven-license.js +52 -0
  51. package/lib/maven-repo.js +3 -0
  52. package/lib/maven-version.js +8 -3
  53. package/lib/nvd.js +13 -3
  54. package/lib/osv.js +68 -19
  55. package/lib/outdated.js +3 -0
  56. package/lib/priority.js +90 -0
  57. package/lib/purl.js +77 -0
  58. package/lib/retire.js +3 -0
  59. package/lib/sarif-export.js +134 -0
  60. package/lib/sbom-export.js +153 -0
  61. package/lib/scan-completeness.js +3 -0
  62. package/lib/snyk.js +3 -0
  63. package/lib/suppress.js +113 -0
  64. package/lib/transitive.js +3 -0
  65. package/lib/ui.js +3 -0
  66. package/package.json +48 -2
package/fad-checker.js CHANGED
@@ -169,12 +169,27 @@ program
169
169
  .option("-e, --exclude <exclude>", "regex of groupId to exclude, e.g. '^(client|private)\\.'")
170
170
  .option("-v, --verbose", "verbose")
171
171
  // Defaults: report + transitive + allLibs all ON. Use --no-* to disable.
172
- .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)")
173
173
  .option("--no-transitive", "skip transitive dependency resolution")
174
174
  .option("--no-all-libs", "skip Maven Central queries (outdated check + missing-on-central check)")
175
175
  .option("--no-osv", "skip OSV.dev (Google/GitHub aggregated Maven CVE feed)")
176
176
  .option("--no-nvd", "skip NIST NVD enrichment of matched CVEs")
177
- .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")
178
193
  .option("--set-nvd-key <key>", "save NVD API key to ~/.fad-checker/config.json (10× faster NVD enrichment)")
179
194
  .option("--show-config", "print the persisted ~/.fad-checker/config.json")
180
195
  .option("--export-cache <file>", "tar.gz/zip the ~/.fad-checker/ caches to <file> (excludes config.json by default)")
@@ -191,13 +206,16 @@ program
191
206
  .option("--no-retire", "skip retire.js vendored-JS scan")
192
207
  .option("--retire-refresh", "ignore retire cache and re-scan")
193
208
  .option("--transitive-depth <n>", "max transitive depth", "6")
194
- .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")
195
210
  .option("--no-maven", "skip the Maven codec")
196
211
  .option("--no-npm", "skip the npm codec")
197
212
  .option("--no-yarn", "skip the Yarn codec")
198
213
  .option("--no-nuget", "skip the NuGet (C#/.NET) codec")
199
214
  .option("--no-composer", "skip the Composer (PHP) codec")
200
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")
201
219
  .option("--no-js", "alias: skip JS/npm/yarn manifests even if present (Maven-only)")
202
220
  .option("--repo <url...>", "extra Maven repository URL(s) to try before Maven Central. Supports https://user:pass@host/path/. Repeatable.")
203
221
  .option("--add-repo <name>", "persist a Maven repo: --add-repo <name> <url> [--auth user:pass]")
@@ -209,6 +227,18 @@ program.parse(process.argv);
209
227
  const options = program.opts();
210
228
  const deps2Exclude = options.exclude ? new RegExp(options.exclude) : null;
211
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
+ }
212
242
  // Read-only when no target is given. No need for an explicit --test flag.
213
243
  const readOnly = !options.target;
214
244
 
@@ -223,10 +253,18 @@ if (options.src && options.importAnonymized) {
223
253
  }
224
254
 
225
255
  if (options.src && options.target) {
226
- const rel = path.relative(path.resolve(options.src), path.resolve(options.target));
227
- const isSubdir = !rel || (!rel.startsWith("..") && !path.isAbsolute(rel));
228
- if (isSubdir) {
229
- 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"));
230
268
  process.exit(1);
231
269
  }
232
270
  }
@@ -295,7 +333,7 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
295
333
  const { resolveActiveCodecs } = require("./lib/codecs/select");
296
334
  const eco = (options.ecosystem || "auto").toLowerCase();
297
335
  const detected = (eco === "auto") ? detectCodecs(options.src).map(c => c.id) : allCodecs().map(c => c.id);
298
- 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);
299
337
  const activeIds = resolveActiveCodecs(eco, detected, { noCodecs, noJs: !options.js });
300
338
  const runMaven = activeIds.includes("maven");
301
339
  const runNpm = activeIds.includes("npm") || activeIds.includes("yarn");
@@ -309,7 +347,7 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
309
347
  const codec = getCodec(id);
310
348
  let res;
311
349
  try {
312
- 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 });
313
351
  } catch (err) {
314
352
  console.warn(chalk.red(`❌ ${id} collect failed:`), chalk.dim(err.message));
315
353
  continue;
@@ -322,13 +360,18 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
322
360
  // --- Collection summary ---
323
361
  ui.section("Collection");
324
362
  const ecoCount = {};
325
- for (const d of resolved.values()) ecoCount[d.ecosystem] = (ecoCount[d.ecosystem] || 0) + 1;
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
+ }
326
368
  if (runMaven) ui.ok(`${chalk.bold("Maven".padEnd(8))} ${mavenCtx ? mavenCtx.pomFiles.length + " module(s) · " : ""}${ecoCount.maven || 0} direct dep(s)`);
327
369
  if (runNpm) ui.ok(`${chalk.bold("npm/yarn".padEnd(8))} ${ecoCount.npm || 0} dep(s)`);
328
370
  for (const [id, n] of Object.entries(ecoCount)) {
329
371
  if (id === "maven" || id === "npm") continue;
330
372
  ui.ok(`${chalk.bold(((getCodec(id)?.label) || id).padEnd(8))} ${n} dep(s)`);
331
373
  }
374
+ if (embeddedCount) ui.ok(`${chalk.bold("Embedded".padEnd(8))} ${embeddedCount} coord(s) in .jar/.war/.ear`);
332
375
  if (!ecoCount.maven && !ecoCount.npm && !Object.keys(ecoCount).length) ui.warn("no dependencies found in the source tree");
333
376
  if (collectWarnings.length) {
334
377
  ui.warn(`${collectWarnings.length} manifest warning(s) — best-effort / no lockfile:`);
@@ -429,10 +472,12 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
429
472
  else ui.info(chalk.dim(`${wrotePom} POM(s) cleanable (read-only — pass -t <dir> to write them)`));
430
473
  }
431
474
 
432
- // ---------- Report flow (CVE / EOL / Obsolete) ----------
433
- if (options.report) {
434
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, collectWarnings });
435
- } else if (!readOnly) {
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) {
436
481
  ui.section("Next step");
437
482
  ui.info(`run Snyk on the cleaned tree:`);
438
483
  console.log(" " + chalk.white(`cd ${options.target} && snyk test --json --all-projects | snyk-to-html -o ../snyk-deps-check.html`));
@@ -481,8 +526,13 @@ async function runReportFlow(resolved, ecoFlags = {}) {
481
526
  const willOsv = !!options.osv;
482
527
  const willOutdated = !!options.allLibs;
483
528
  const willNvd = !!options.nvd;
529
+ const willEpss = !!options.epss;
530
+ const willKev = !!options.kev;
531
+ const willLicenses = !!options.licenses;
484
532
  const willRetire = !!options.retire;
485
- const totalSteps = [willTransitive, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willRetire].filter(Boolean).length;
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;
486
536
  const progress = new ui.Progress(totalSteps);
487
537
 
488
538
  if (willTransitive) {
@@ -520,6 +570,10 @@ async function runReportFlow(resolved, ecoFlags = {}) {
520
570
  catch (err) { st.fail(err.message); }
521
571
  }
522
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 = [];
576
+
523
577
  // 3. Obsolete / deprecated — local curated list, instant (no network step).
524
578
  let obsoleteResults = [];
525
579
  try { obsoleteResults = outdated.checkObsoleteDeps(resolved); }
@@ -544,6 +598,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
544
598
  const npmReg = await checkNpmRegistryDeps(resolved, { verbose, offline, allLibs: options.allLibs, onProgress: (p, t) => st.tick(p, t) });
545
599
  obsoleteResults = obsoleteResults.concat(npmReg.deprecated);
546
600
  outdatedResults = outdatedResults.concat(npmReg.outdated);
601
+ licenseFindings = licenseFindings.concat(npmReg.licensed || []);
547
602
  st.done(`${npmReg.deprecated.length} deprecated, ${npmReg.outdated.length} outdated`);
548
603
  } catch (err) { st.fail(err.message); }
549
604
  }
@@ -556,6 +611,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
556
611
  const reg = await codec.checkRegistry(resolved, { verbose, offline, allLibs: options.allLibs, onProgress: (p, t) => st.tick(p, t) });
557
612
  obsoleteResults = obsoleteResults.concat(reg.deprecated || []);
558
613
  outdatedResults = outdatedResults.concat(reg.outdated || []);
614
+ licenseFindings = licenseFindings.concat(reg.licensed || []);
559
615
  st.done(`${(reg.deprecated || []).length} deprecated, ${(reg.outdated || []).length} outdated`);
560
616
  } catch (err) { st.fail(err.message); }
561
617
  }
@@ -605,6 +661,40 @@ async function runReportFlow(resolved, ecoFlags = {}) {
605
661
  }
606
662
  }
607
663
 
664
+ // 4e. EPSS — exploit-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); }
689
+ }
690
+ }
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
+
608
698
  // 5. retire.js — native "vendored" scanner contributed by the npm codec. Scans
609
699
  // vendored JS files (jquery copies, bootstrap, pdf.js, …) that live in the
610
700
  // source tree without any lockfile to back them.
@@ -649,10 +739,34 @@ async function runReportFlow(resolved, ecoFlags = {}) {
649
739
  // full per-bucket list (including cpeFiltered) so the HTML report can render
650
740
  // its "Likely false positives" appendix — only the CLI headline excludes
651
741
  // cpeFiltered to avoid alarming on triaged-out matches.
652
- const prodMatches = cveMatches.filter(m => !m.dep?.isDev);
653
- const devMatches = cveMatches.filter(m => m.dep?.isDev);
654
- const prodActive = prodMatches.filter(m => !m.cpeFiltered);
655
- 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;
656
770
  const cpeFilteredCount = (prodMatches.length - prodActive.length) + (devMatches.length - devActive.length);
657
771
 
658
772
  const stats = computeStats(prodActive);
@@ -671,15 +785,26 @@ async function runReportFlow(resolved, ecoFlags = {}) {
671
785
 
672
786
  ui.section("Results");
673
787
 
674
- heading("CVE · production", prodActive.length, fmtStats(stats));
788
+ heading("CVE · production", prodActive.length, fmtStats(stats) + (kevCount ? " " + chalk.bgRed.white(` ${kevCount} KEV `) : ""));
675
789
  for (const m of prodActive.slice(0, 12)) {
676
- 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}`));
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);
677
793
  }
678
794
  if (prodActive.length > 12) console.log(chalk.dim(` …and ${prodActive.length - 12} more (see report)`));
679
795
  if (cpeFilteredCount) console.log(chalk.dim(` ${cpeFilteredCount} likely false positive(s) → report appendix`));
680
796
 
681
797
  heading("CVE · dev", devActive.length, devActive.length ? fmtStats(devStats) : "");
682
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}`));
804
+ }
805
+ if (embeddedActive.length > 8) console.log(chalk.dim(` …and ${embeddedActive.length - 8} more (see report ch.1B)`));
806
+ }
807
+
683
808
  heading("EOL frameworks", eolResults.length);
684
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)));
685
810
  if (eolResults.length > 8) console.log(chalk.dim(` …and ${eolResults.length - 8} more`));
@@ -708,8 +833,27 @@ async function runReportFlow(resolved, ecoFlags = {}) {
708
833
  }
709
834
  }
710
835
 
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
+ }
855
+
711
856
  const reportDir = options.reportOutput || "./fad-checker-report";
712
- await fs.promises.mkdir(reportDir, { recursive: true });
713
857
  // --import-anonymized has no source tree; keep the report path-free (consistent
714
858
  // with the anonymized descriptor it was fed).
715
859
  const srcResolved = options.src ? path.resolve(options.src) : null;
@@ -720,37 +864,116 @@ async function runReportFlow(resolved, ecoFlags = {}) {
720
864
  toolVersion: pkg.version,
721
865
  cveDataDate,
722
866
  };
723
- const { htmlPath, docPath } = await writeReports({
724
- cveMatches: prodMatches,
725
- devCveMatches: devMatches,
726
- retireMatches,
727
- eolResults,
728
- obsoleteResults,
729
- outdatedResults,
730
- resolvedDeps: resolved,
731
- projectInfo,
732
- outputDir: reportDir,
733
- warnings: [
734
- ...npmWarnings,
735
- ...scanWarnings,
736
- ...(privateLibIds.length ? [{
737
- type: "private-libs",
738
- count: privateLibIds.length,
739
- // Enrich each private lib with the relative path(s) of the pom(s)
740
- // that declare it, so the team knows where to look.
741
- items: privateLibIds.map(id => {
742
- const dep = resolved.get(id);
743
- const paths = (dep?.pomPaths || []).map(p => path.relative(options.src, p));
744
- return { id, manifestPaths: paths };
745
- }),
746
- 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.`,
747
- }] : []),
748
- ],
749
- });
750
- ui.section("Report");
751
- ui.ok(chalk.white(htmlPath));
752
- ui.ok(chalk.white(docPath));
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
+ }
753
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
+ }
754
977
  }
755
978
 
756
979
  /**
@@ -760,7 +983,9 @@ async function runReportFlow(resolved, ecoFlags = {}) {
760
983
  */
761
984
  function mergeBySource(existing, additions) {
762
985
  const byKey = new Map();
763
- 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}`;
764
989
  for (const m of existing || []) byKey.set(k(m), { ...m, source: m.source || "fad" });
765
990
  for (const m of additions || []) {
766
991
  const key = k(m);
@@ -12,6 +12,9 @@
12
12
  *
13
13
  * The format is selected from the file extension. Tar uses native `tar`
14
14
  * binary (zero new deps).
15
+ *
16
+ * @author: N.BRAUN
17
+ * @email: pp9ping@gmail.com
15
18
  */
16
19
  const fs = require("fs");
17
20
  const path = require("path");
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Pas de classe imposée : un codec est un objet litéral exportant ces clés.
5
5
  * Voir docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md.
6
+ *
7
+ * @author: N.BRAUN
8
+ * @email: pp9ping@gmail.com
6
9
  */
7
10
  const REQUIRED_KEYS = [
8
11
  "id", "label", "osvEcosystem", "manifestNames",
@@ -7,6 +7,9 @@
7
7
  * used only as a no-lock fallback (pinned exact versions).
8
8
  *
9
9
  * Composer names are "vendor/package", case-insensitive (lowercased by convention).
10
+ *
11
+ * @author: N.BRAUN
12
+ * @email: pp9ping@gmail.com
10
13
  */
11
14
  const fs = require("fs");
12
15
 
@@ -6,6 +6,9 @@
6
6
  * → { package: { abandoned: bool|string, versions: { "<v>": {...} } } }
7
7
  *
8
8
  * Cached in ~/.fad-checker/packagist-cache.json for 24h.
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
9
12
  */
10
13
  const fs = require("fs");
11
14
  const path = require("path");
@@ -30,14 +33,18 @@ function cmp(a, b) {
30
33
 
31
34
  // Extract {abandoned, outdated} from a Packagist `package` object.
32
35
  function packagistToFindings(pkg, { version }) {
33
- const out = { abandoned: null, outdated: null };
36
+ const out = { abandoned: null, outdated: null, license: null };
34
37
  if (pkg.abandoned === true) out.abandoned = { replacement: null };
35
38
  else if (typeof pkg.abandoned === "string") out.abandoned = { replacement: pkg.abandoned };
36
- const stable = Object.keys(pkg.versions || {}).map(v => v.replace(/^v/, "")).filter(isStable);
39
+ const versions = pkg.versions || {};
40
+ const stable = Object.keys(versions).map(v => v.replace(/^v/, "")).filter(isStable);
37
41
  if (stable.length) {
38
42
  const latest = stable.sort(cmp).at(-1);
39
43
  if (latest && cmp(latest, String(version).replace(/^v/, "")) > 0) out.outdated = { latest };
40
44
  }
45
+ // Packagist version objects carry a `license` array (e.g. ["MIT"]).
46
+ const vEntry = versions[version] || versions[`v${version}`] || versions[Object.keys(versions)[0]];
47
+ if (vEntry?.license && vEntry.license.length) out.license = vEntry.license;
41
48
  return out;
42
49
  }
43
50
 
@@ -55,7 +62,7 @@ async function fetchPackage(name, { offline }) {
55
62
  async function checkComposerRegistryDeps(deps, opts = {}) {
56
63
  const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
57
64
  const targets = [...deps.values()].filter(d => d.ecosystem === "composer" && d.version);
58
- const result = { deprecated: [], outdated: [] };
65
+ const result = { deprecated: [], outdated: [], licensed: [] };
59
66
  if (!targets.length) return result;
60
67
  const cache = loadCache();
61
68
  const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
@@ -69,12 +76,13 @@ async function checkComposerRegistryDeps(deps, opts = {}) {
69
76
  const pkg = await fetchPackage(name, { offline });
70
77
  if (pkg && !pkg.error) {
71
78
  const f = packagistToFindings(pkg, { version: t.version });
72
- ex = { abandoned: f.abandoned, latest: f.outdated?.latest || null };
79
+ ex = { abandoned: f.abandoned, latest: f.outdated?.latest || null, license: f.license || null };
73
80
  cache.entries[key] = ex;
74
81
  } else {
75
- ex = { abandoned: null, latest: null };
82
+ ex = { abandoned: null, latest: null, license: null };
76
83
  }
77
84
  }
85
+ if (ex.license) result.licensed.push({ dep: t, licenses: ex.license, source: "packagist" });
78
86
  if (ex.abandoned) {
79
87
  result.deprecated.push({ dep: t, severity: "MEDIUM", replacement: ex.abandoned.replacement, reason: "Package marked abandoned on Packagist", source: "packagist" });
80
88
  if (verbose) process.stdout.write(` abandoned: ${name}@${t.version}\n`);
@@ -4,6 +4,9 @@
4
4
  * Vuln scanning is OSV (ecosystem "Packagist", wired in Plan A). This codec adds
5
5
  * collection (composer.lock, composer.json fallback), Packagist registry
6
6
  * (abandoned + outdated), and EOL via endoflife.date.
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
7
10
  */
8
11
  const fs = require("fs");
9
12
  const path = require("path");
@@ -0,0 +1,65 @@
1
+ /**
2
+ * lib/codecs/go/parse.js — parse go.mod (authoritative for selected versions on
3
+ * Go ≥1.17, which lists the full pruned module graph) with go.sum as fallback.
4
+ *
5
+ * Versions are stored WITHOUT the leading "v" (OSV's Go ecosystem and our
6
+ * version comparisons expect bare semver; the purl layer re-adds context).
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
10
+ */
11
+ const fs = require("fs");
12
+ const { compareMavenVersions } = require("../../maven-version");
13
+
14
+ function stripV(v) { return String(v || "").replace(/^v/, ""); }
15
+
16
+ /** Parse go.mod → { module, deps: [{ name, version, scope, isDev }] }. */
17
+ function parseGoMod(text) {
18
+ const out = { module: null, deps: [] };
19
+ const lines = String(text || "").split(/\r?\n/);
20
+ let inRequire = false;
21
+ const seen = new Set();
22
+ const addReq = (name, ver, indirect) => {
23
+ if (!name || !ver) return;
24
+ const key = `${name}@${ver}`;
25
+ if (seen.has(key)) return;
26
+ seen.add(key);
27
+ out.deps.push({ name, version: stripV(ver), scope: indirect ? "transitive" : "compile", isDev: false });
28
+ };
29
+ for (let raw of lines) {
30
+ const noComment = raw.split("//")[0].trim();
31
+ const indirect = /\/\/\s*indirect/.test(raw);
32
+ if (noComment.startsWith("module ")) { out.module = noComment.slice(7).trim(); continue; }
33
+ if (noComment === "require (") { inRequire = true; continue; }
34
+ if (inRequire && noComment === ")") { inRequire = false; continue; }
35
+ if (inRequire) {
36
+ const m = noComment.match(/^(\S+)\s+(\S+)/);
37
+ if (m) addReq(m[1], m[2], indirect);
38
+ continue;
39
+ }
40
+ const single = noComment.match(/^require\s+(\S+)\s+(\S+)/);
41
+ if (single) addReq(single[1], single[2], indirect);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** Parse go.sum → deps (fallback when go.mod has no require list). Highest version per module. */
47
+ function parseGoSum(text) {
48
+ const byMod = new Map();
49
+ for (const raw of String(text || "").split(/\r?\n/)) {
50
+ const m = raw.trim().match(/^(\S+)\s+(v\S+?)(\/go\.mod)?\s+h1:/);
51
+ if (!m) continue;
52
+ const name = m[1];
53
+ const ver = stripV(m[2]);
54
+ // go.sum lists every version in the module graph; keep the highest (the
55
+ // comment promised this but the code kept the first one encountered).
56
+ const prev = byMod.get(name);
57
+ if (!prev || compareMavenVersions(ver, prev) > 0) byMod.set(name, ver);
58
+ }
59
+ return { deps: [...byMod.entries()].map(([name, version]) => ({ name, version, scope: "transitive", isDev: false })) };
60
+ }
61
+
62
+ function parseGoModFile(fp) { return parseGoMod(fs.readFileSync(fp, "utf8")); }
63
+ function parseGoSumFile(fp) { return parseGoSum(fs.readFileSync(fp, "utf8")); }
64
+
65
+ module.exports = { parseGoMod, parseGoSum, parseGoModFile, parseGoSumFile, stripV };