fad-checker 2.2.4 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  > **F**abulous **A**utonomous **D**ependency **C**hecker<br>
9
9
  > AKA **F**uckin' **A**utonomous **D**ependency **C**hecker<br>
10
10
 
11
- `fad-checker` audits **Maven · npm · Yarn · pnpm · Composer · PyPI · NuGet · Go · Ruby**, vendored JavaScript and committed native binaries in any source tree — multi-module, monorepo, polyglot — and produces a self-contained **HTML + Word report** (CVE prioritised by EPSS + CISA KEV, EOL, obsolete, outdated, licenses) plus **CycloneDX SBOM / CSAF VEX / SARIF / JSON** exports. **No build tools, no Docker, no network needed** — it reads lockfiles and manifests straight off disk.
11
+ `fad-checker` audits **Maven · Gradle · npm · Yarn · pnpm · Composer · PyPI · NuGet · Go · Ruby**, vendored JavaScript and committed native binaries in any source tree — multi-module, monorepo, polyglot — and produces a self-contained **HTML + Word report** (CVE prioritised by EPSS + CISA KEV, EOL, obsolete, outdated, licenses) plus **CycloneDX SBOM / CSAF VEX / SARIF / JSON** exports. **No build tools, no Docker, no network needed** — it reads lockfiles and manifests straight off disk.
12
12
 
13
13
  🌐 **[Project site & docs →](https://n8tz.github.io/fad-checker/)**
14
14
 
@@ -16,11 +16,11 @@
16
16
 
17
17
  ## Features
18
18
 
19
- - **9 ecosystems in one pass** — Maven, npm/Yarn/pnpm, Composer (PHP), PyPI, NuGet, Go, Ruby; plus **vendored JS** (retire.js), committed **native binaries** (`.dll`/`.exe`/`.so`/`.dylib`, identified by checksum via deps.dev + CIRCL) and **embedded JARs** (fat-jars/war/ear, unzipped in-memory).
20
- - **No build tools** — reads `pom.xml`, `package-lock`/`yarn.lock`/`pnpm-lock`, `composer.lock`, `poetry`/`Pipfile`/`uv`/`pdm` locks, `packages.lock.json`/`*.csproj`, `go.mod`, `Gemfile.lock` directly. No `mvn`/`npm install`/`pip`/`dotnet restore`/`go build`/`bundle`, no `node_modules/`. → [how it stays build-free](docs/COMPARISON.md#how-its-autonomous-no-build-tools)
19
+ - **10 ecosystems in one pass** — Maven, **Gradle**, npm/Yarn/pnpm, Composer (PHP), PyPI, NuGet, Go, Ruby; plus **vendored JS** (retire.js), committed **native binaries** (`.dll`/`.exe`/`.so`/`.dylib`, identified by checksum via deps.dev + CIRCL) and **embedded JARs** (fat-jars/war/ear, unzipped in-memory).
20
+ - **No build tools** — reads `pom.xml`, `build.gradle(.kts)`/`gradle.lockfile`/`libs.versions.toml`, `package-lock`/`yarn.lock`/`pnpm-lock`, `composer.lock`, `poetry`/`Pipfile`/`uv`/`pdm` locks, `packages.lock.json`/`*.csproj`, `go.mod`, `Gemfile.lock` directly. No `mvn`/`gradle`/`npm install`/`pip`/`dotnet restore`/`go build`/`bundle`, no `node_modules/`. → [how it stays build-free](docs/COMPARISON.md#how-its-autonomous-no-build-tools)
21
21
  - **CVE, merged & prioritised** — CVEProject + OSV.dev + NVD, CPE/version cross-checked to cut false positives, ranked **CISA KEV → EPSS → CVSS**.
22
22
  - **Per-module Maven version mediation** — recovers vulnerable transitive versions that a global `<dependencyManagement>` pin hides in another module (lifted Snyk-corroborated coverage **156 → 181** on a real 25-module reactor, finding CVEs a single Snyk scan missed).
23
- - **Air-gapped / PASSI** — **zero network under `--offline`** (regression-tested), offline Maven transitive resolution, and `--osv-db` for cache-independent offline OSV recall. → [Air-gapped](#air-gapped--passi-audits)
23
+ - **Air-gapped** — **zero network under `--offline`** (regression-tested), offline Maven transitive resolution, and `--osv-db` for cache-independent offline OSV recall. → [Air-gapped](#air-gapped-audits)
24
24
  - **Supply-chain risk** — known-**malicious** advisories (`MAL-`, always block the CI gate) + suspected **typosquats** (`--typosquat`).
25
25
  - **Lifecycle** — EOL (endoflife.date), obsolete/deprecated, outdated — across every ecosystem.
26
26
  - **Licenses** *(opt-in `--licenses`)* — SPDX-normalised, copyleft/proprietary flagged.
@@ -63,13 +63,13 @@ A single self-contained binary (no Node), from-source install and shell completi
63
63
  | **5. Obsolete libraries** | curated list (Maven) + registry maintainer flags | log4j 1.x, jackson-mapper-asl, joda-time, …; npm `deprecated`, Composer `abandoned`, PyPI `yanked`/inactive, NuGet `deprecation` |
64
64
  | **6. Outdated libraries** | Maven Central + npm / Packagist / PyPI / NuGet registries | Available newer versions, with release dates |
65
65
  | **7. Licenses** *(opt-in: `--licenses`)* | registry metadata + Maven POMs → SPDX policy | Each dep's license normalised to SPDX and classified; copyleft (GPL/AGPL/LGPL/MPL), proprietary and unknown flagged for review |
66
- | **8. Fix Recommendations** | computed | Per-ecosystem pin recipes: Maven `<dependencyManagement>`, npm `overrides`, yarn `resolutions`, `composer require`, `pip install`, `dotnet add package` |
66
+ | **8. Fix Recommendations** | computed | Per-ecosystem pin recipes: Maven `<dependencyManagement>`, Gradle `constraints { }`, npm `overrides`, yarn `resolutions`, `composer require`, `pip install`, `dotnet add package` |
67
67
 
68
68
  The HTML report opens in any browser, contains every detail (CVSS vectors, references, full descriptions, CPE configurations, via-paths for transitives) and ships a Word-compatible `.doc` twin. Every match carries a **composite priority** (KEV-exploited > EPSS likelihood > CVSS severity), and the run can additionally emit a **CycloneDX 1.6 SBOM** (`--report-sbom`, vulnerabilities inline) and a **CSAF 2.0 VEX** (`--report-csaf`) for downstream tooling.
69
69
 
70
70
  <p align="center"><img src="docs/assets/report.png" alt="fad-checker HTML report — executive summary with severity tiles and a detailed CVE table with CWE, descriptions and fix versions" width="900"></p>
71
71
 
72
- ## Air-gapped / PASSI audits
72
+ ## Air-gapped audits
73
73
 
74
74
  > **Zero-data-sent guarantee.** Under `--offline`, fad-checker makes **no network calls
75
75
  > whatsoever** — it reads only the warmed `~/.fad-checker/` caches and never transmits a
@@ -80,7 +80,7 @@ The HTML report opens in any browser, contains every detail (CVSS vectors, refer
80
80
  > mainstream OSS scanners, fad also resolves the **Maven transitive graph** offline — so on
81
81
  > an air-gapped multi-module project it finds the transitive CVEs they can't.
82
82
 
83
- When the audited system is **offline / confidential** (typical of a PASSI engagement) it
83
+ When the audited system is **offline / confidential** (typical of a regulated or air-gapped audit) it
84
84
  can't reach OSV / NVD / Maven Central / npm. Split the work across machines while keeping
85
85
  **zero environment information** off the secure enclave: an anonymized descriptor carries
86
86
  only **public package coordinates** — no filesystem paths, no registry URLs, no
package/fad-checker.js CHANGED
@@ -229,7 +229,7 @@ program
229
229
  .option("--export-cache <file>", "tar.gz/zip the ~/.fad-checker/ caches to <file> (excludes config.json by default)")
230
230
  .option("--import-cache <file>", "restore ~/.fad-checker/ from a previously exported archive (existing dir is moved to .bak unless --force)")
231
231
  .option("--include-config", "with --export-cache: also bundle config.json (contains the NVD API key)")
232
- .option("--export-anonymized <file>", "offline: write an anonymized dependency descriptor (public coordinates only, no paths/URLs) for PASSI audits, then exit")
232
+ .option("--export-anonymized <file>", "offline: write an anonymized dependency descriptor (public coordinates only, no paths/URLs) for air-gapped audits, then exit")
233
233
  .option("--import-anonymized <file>", "online: scan an anonymized descriptor (no --src) to warm the caches; pair with --export-cache for offline reporting")
234
234
  .option("--force", "with --import-cache: replace ~/.fad-checker/ without backup")
235
235
  .option("--report-output <dir>", "report output directory", "./fad-checker-report")
@@ -246,6 +246,7 @@ program
246
246
  .option("--transitive-depth <n>", "max transitive depth", "6")
247
247
  .option("--ecosystem <list>", "codecs to run: auto|all|<comma list> e.g. maven,npm,nuget,composer,pypi,go,ruby (default: auto = detected)", "auto")
248
248
  .option("--no-maven", "skip the Maven codec")
249
+ .option("--no-gradle", "skip the Gradle codec")
249
250
  .option("--no-npm", "skip the npm codec")
250
251
  .option("--no-yarn", "skip the Yarn codec")
251
252
  .option("--no-nuget", "skip the NuGet (C#/.NET) codec")
@@ -451,10 +452,10 @@ async function timedPhase(label, fn) {
451
452
  ].filter(Boolean))];
452
453
  const defaultExcludes = options.defaultExcludes !== false;
453
454
  const walkOpts = { excludePath, defaultExcludes };
454
- // PASSI phase 1 (--export-anonymized) is a purely local operation — parse the tree,
455
+ // Anonymized export (phase 1, --export-anonymized) is a purely local operation — parse the tree,
455
456
  // emit public coordinates, exit. Force offline so no source ever touches the network.
456
457
  if (options.exportAnonymized) options.offline = true;
457
- const runMode = options.exportAnonymized ? "offline (PASSI phase 1 · export descriptor)"
458
+ const runMode = options.exportAnonymized ? "offline (anonymized export · phase 1)"
458
459
  : options.importAnonymized ? "import descriptor"
459
460
  : (options.offline ? "offline" : "online");
460
461
  if (options.src) ui.kv("source", chalk.white(options.src));
@@ -467,7 +468,7 @@ async function timedPhase(label, fn) {
467
468
 
468
469
  let wrotePom = 0;
469
470
 
470
- // --- PASSI phase 2: import an anonymized descriptor instead of collecting ---
471
+ // --- Anonymized phase 2: import a descriptor instead of collecting ---
471
472
  // Scans the descriptor's public coordinates online to WARM the coordinate-keyed
472
473
  // caches (OSV/NVD/CVE/registry/EOL) + retire signatures. Pair with --export-cache.
473
474
  if (options.importAnonymized) {
@@ -488,6 +489,12 @@ async function timedPhase(label, fn) {
488
489
  const { warmRetireSignatures } = require("./lib/retire");
489
490
  await warmRetireSignatures({ verbose });
490
491
  }
492
+ // --import-anonymized is a cache-WARMING step (pair with --export-cache), not a
493
+ // reporting one: the path-bearing report is produced later, offline, from the warmed
494
+ // cache against the real source tree. So suppress the default HTML+doc output here
495
+ // (still honor an explicit --report-<type> if a user really wants a path-free one).
496
+ const anyReportRequested = [options.reportHtml, options.reportDoc, options.reportSbom, options.reportCsaf, options.reportJson, options.reportSarif].some(v => v !== undefined);
497
+ if (!anyReportRequested) options.report = false;
491
498
  await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, regMap, collectWarnings: [] });
492
499
  return;
493
500
  }
@@ -503,11 +510,12 @@ async function timedPhase(label, fn) {
503
510
  // project), and detectCodecs' manifest-glob matcher misses versioned sonames
504
511
  // (libz.so.1). Always make it a candidate in auto mode; --no-binaries removes it.
505
512
  if (eco === "auto" && !detected.includes("binary")) detected.push("binary");
506
- const noCodecs = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby"].filter(id => options[id] === false);
513
+ const noCodecs = ["maven", "gradle", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby"].filter(id => options[id] === false);
507
514
  // `--no-binaries` maps to options.binaries (plural) but the codec id is `binary`.
508
515
  if (options.binaries === false) noCodecs.push("binary");
509
516
  const activeIds = resolveActiveCodecs(eco, detected, { noCodecs, noJs: !options.js });
510
517
  const runMaven = activeIds.includes("maven");
518
+ const runGradle = activeIds.includes("gradle");
511
519
  const runNpm = activeIds.includes("npm") || activeIds.includes("yarn");
512
520
 
513
521
  // --- Collect deps from every active codec into one Map (coordKeys never collide) ---
@@ -516,7 +524,13 @@ async function timedPhase(label, fn) {
516
524
  ui.section("Collection");
517
525
  const resolved = new Map();
518
526
  let mavenCtx = null;
527
+ let gradleCtx = null;
519
528
  const collectWarnings = [];
529
+ // Every descriptor file each codec actually parsed (tagged with its ecosystemType =
530
+ // codec id), so the report's "Scanned dependency descriptors" appendix is a COMPLETE
531
+ // inventory — including files parsed that contributed no scannable dep (ranges-only /
532
+ // no lockfile), which would otherwise be invisible.
533
+ const parsedManifests = [];
520
534
  for (const id of activeIds) {
521
535
  if (id === "yarn") continue; // the npm codec already collects yarn.lock
522
536
  const codec = getCodec(id);
@@ -529,7 +543,9 @@ async function timedPhase(label, fn) {
529
543
  }
530
544
  for (const [k, v] of res.deps) resolved.set(k, v);
531
545
  if (res.warnings?.length) collectWarnings.push(...res.warnings);
546
+ for (const p of (res.parsedManifests || [])) parsedManifests.push({ path: p, ecosystemType: id });
532
547
  if (id === "maven") mavenCtx = res._maven;
548
+ if (id === "gradle") gradleCtx = res._gradle;
533
549
  }
534
550
 
535
551
  // --- Collection summary ---
@@ -539,12 +555,15 @@ async function timedPhase(label, fn) {
539
555
  for (const d of resolved.values()) {
540
556
  if (d.provenance === "embedded") { embeddedCount++; continue; } // counted separately below
541
557
  if (d.provenance === "binary") { binaryCount++; continue; } // committed native libs, no manifest
542
- ecoCount[d.ecosystem] = (ecoCount[d.ecosystem] || 0) + 1;
558
+ // Key by ecosystemType so Gradle (ecosystem "maven") gets its own line/label.
559
+ const ecoKey = d.ecosystemType || d.ecosystem;
560
+ ecoCount[ecoKey] = (ecoCount[ecoKey] || 0) + 1;
543
561
  }
544
562
  if (runMaven) ui.ok(`${chalk.bold("Maven".padEnd(8))} ${mavenCtx ? mavenCtx.pomFiles.length + " module(s) · " : ""}${ecoCount.maven || 0} direct dep(s)`);
563
+ if (runGradle) ui.ok(`${chalk.bold("Gradle".padEnd(8))} ${ecoCount.gradle || 0} direct dep(s)`);
545
564
  if (runNpm) ui.ok(`${chalk.bold("npm/yarn".padEnd(8))} ${ecoCount.npm || 0} dep(s)`);
546
565
  for (const [id, n] of Object.entries(ecoCount)) {
547
- if (id === "maven" || id === "npm") continue;
566
+ if (id === "maven" || id === "gradle" || id === "npm") continue;
548
567
  ui.ok(`${chalk.bold(((getCodec(id)?.label) || id).padEnd(8))} ${n} dep(s)`);
549
568
  }
550
569
  if (embeddedCount) ui.ok(`${chalk.bold("Embedded".padEnd(8))} ${embeddedCount} coord(s) in .jar/.war/.ear`);
@@ -556,7 +575,7 @@ async function timedPhase(label, fn) {
556
575
  if (collectWarnings.length > 5) ui.info(chalk.dim(`…and ${collectWarnings.length - 5} more`));
557
576
  }
558
577
 
559
- // --- PASSI phase 1: export an anonymized descriptor and exit (no network, no report) ---
578
+ // --- Anonymized phase 1: export a descriptor and exit (no network, no report) ---
560
579
  if (options.exportAnonymized) {
561
580
  const { serializeDeps } = require("./lib/deps-descriptor");
562
581
  const pkgVersion = require("./package.json").version;
@@ -697,7 +716,7 @@ async function timedPhase(label, fn) {
697
716
  // The scan always runs — it feeds the terminal summary, the file outputs and the
698
717
  // CI gate. Which files get written is decided by the --report-* family inside
699
718
  // (HTML + .doc by default; --no-report writes nothing).
700
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store });
719
+ await runReportFlow(resolved, { activeIds, runMaven, runGradle, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store, gradlePlatformBoms: gradleCtx?.platformBoms || [], parsedManifests });
701
720
  if (!readOnly) {
702
721
  ui.section("Next step");
703
722
  ui.info(`run Snyk on the cleaned tree:`);
@@ -706,7 +725,7 @@ async function timedPhase(label, fn) {
706
725
  })();
707
726
 
708
727
  async function runReportFlow(resolved, ecoFlags = {}) {
709
- const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null } = ecoFlags;
728
+ const { activeIds = [], runMaven = true, runGradle = false, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null, gradlePlatformBoms = [], parsedManifests = [] } = ecoFlags;
710
729
  const registriesFor = eco => regMap[eco] || [];
711
730
  const { expandWithTransitives } = require("./lib/cve-match");
712
731
  const { writeReports, computeStats } = require("./lib/cve-report");
@@ -736,11 +755,14 @@ async function runReportFlow(resolved, ecoFlags = {}) {
736
755
  }
737
756
 
738
757
  // Decide which update steps will run (from flags) so the [n/N] counter is accurate.
739
- const cveScanner = runMaven ? (getCodec("maven").nativeScanners || []).find(s => s.kind === "cve") : null;
758
+ // Gradle deps are Maven coordinates (ecosystem "maven"), so the Maven CVE-index scanner
759
+ // and the Maven-Central transitive/BOM/outdated/EOL passes cover them — they're EXCLUDED
760
+ // from the per-codec registry loop below to avoid double-processing.
761
+ const cveScanner = (runMaven || runGradle) ? (getCodec("maven").nativeScanners || []).find(s => s.kind === "cve") : null;
740
762
  const cveIndexExists = fs.existsSync(require("./lib/cve-download").CVE_INDEX_PATH);
741
- const otherRegistryIds = activeIds.filter(id => id !== "maven" && id !== "npm" && id !== "yarn" && getCodec(id)?.checkRegistry);
763
+ const otherRegistryIds = activeIds.filter(id => id !== "maven" && id !== "gradle" && id !== "npm" && id !== "yarn" && getCodec(id)?.checkRegistry);
742
764
  const willCve = !!cveScanner && (!(options.cveOffline || offline) || cveIndexExists);
743
- const willTransitive = !!(options.transitive && runMaven);
765
+ const willTransitive = !!(options.transitive && (runMaven || runGradle));
744
766
  // Per-module version mediation overlay: recover transitive versions the global
745
767
  // transitive pass masks via cross-module depMgmt bleed. Runs after (and only when)
746
768
  // the global pass runs, and needs the parsed store + per-module props.
@@ -751,11 +773,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
751
773
  // never the network — same as transitive resolution).
752
774
  const importBoms = (runMaven && mavenPropsByPom)
753
775
  ? require("./lib/maven-bom").collectImportBoms(mavenPropsByPom) : [];
754
- const willBom = importBoms.length > 0;
776
+ // Gradle `platform(...)` BOMs are the same as Maven `<scope>import</scope>` BOMs — feed
777
+ // them through the same managed-version backfill (e.g. spring-boot-dependencies → all the
778
+ // versionless spring-boot-starter-* declared in build.gradle get their version).
779
+ const gradleBoms = (gradlePlatformBoms || [])
780
+ .map(b => ({ groupId: b.group, artifactId: b.name, version: b.version }))
781
+ .filter(b => b.groupId && b.artifactId && b.version);
782
+ const allBoms = importBoms.concat(gradleBoms);
783
+ const willBom = allBoms.length > 0;
755
784
  const willOsv = !!options.osv;
756
785
  // Local OSV DB import (Maven): offline-complete OSV recall, independent of the per-dep
757
786
  // OSV.dev cache. Opt-in (downloads ~9 MB once); then matches online or offline.
758
- const willOsvDb = !!options.osvDb && runMaven;
787
+ const willOsvDb = !!options.osvDb && (runMaven || runGradle);
759
788
  const willOutdated = !!options.allLibs;
760
789
  const willNvd = !!options.nvd;
761
790
  const willEpss = !!options.epss;
@@ -772,9 +801,10 @@ async function runReportFlow(resolved, ecoFlags = {}) {
772
801
  if (willBom) {
773
802
  const st = progress.start("BOM version resolution (Maven Central)");
774
803
  try {
775
- const { resolveAndBackfill } = require("./lib/maven-bom");
776
- const r = await resolveAndBackfill(mavenPropsByPom, resolved, { repos: mavenRepos, offline, verbose });
777
- st.done(`${r.filled} dep version(s) from ${r.boms} import BOM(s)`);
804
+ const { resolveBomManagedVersions, backfillVersions } = require("./lib/maven-bom");
805
+ const mgmt = await resolveBomManagedVersions(allBoms, { repos: mavenRepos, offline, verbose });
806
+ const filled = backfillVersions(resolved, mgmt);
807
+ st.done(`${filled} dep version(s) from ${allBoms.length} import BOM(s)`);
778
808
  } catch (err) { st.fail(err.message); }
779
809
  }
780
810
 
@@ -811,7 +841,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
811
841
  // Scan-completeness signals — computed NOW (after BOM backfill) so only the deps
812
842
  // still without a concrete version (external BOM truly unreachable, or offline)
813
843
  // are flagged, not the ones we just resolved from spring-boot-dependencies & co.
814
- if (runMaven) {
844
+ if (runMaven || runGradle) {
815
845
  const { detectScanCompletenessWarnings } = require("./lib/scan-completeness");
816
846
  scanWarnings = detectScanCompletenessWarnings(resolved, { ranSnyk: !!options.snyk, ranTransitive: !!options.transitive });
817
847
  }
@@ -903,6 +933,11 @@ async function runReportFlow(resolved, ecoFlags = {}) {
903
933
  const k = `${r.dep.groupId}:${r.dep.artifactId}`;
904
934
  return !eolKeys.has(k) && !obsKeys.has(k);
905
935
  });
936
+ // Outdated is a maintenance signal for deps you actually declare and can bump. An
937
+ // INDIRECT (transitive) dep's "latest" isn't directly actionable — you'd bump the parent
938
+ // — so drop transitives from the Outdated chapter (EOL/obsolete keep them: security).
939
+ // scope==="transitive" is the marker set by the Maven/Gradle resolver and the npm parser.
940
+ outdatedResults = outdatedResults.filter(r => r.dep.scope !== "transitive");
906
941
 
907
942
  // 4b. OSV.dev — Maven-native CVE+GHSA feed (huge recall win over raw CVEProject)
908
943
  if (willOsv) {
@@ -1192,7 +1227,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1192
1227
  let licenseResults = null;
1193
1228
  if (willLicenses) {
1194
1229
  try {
1195
- if (runMaven) {
1230
+ if (runMaven || runGradle) {
1196
1231
  const { collectMavenLicenses } = require("./lib/maven-license");
1197
1232
  licenseFindings = licenseFindings.concat(collectMavenLicenses(resolved));
1198
1233
  }
@@ -1265,7 +1300,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1265
1300
  const { htmlPath, docPath } = await writeReports({
1266
1301
  cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches, vendoredJsInventory,
1267
1302
  eolResults, obsoleteResults, outdatedResults, licenseResults,
1268
- resolvedDeps: resolved, projectInfo, warnings: reportWarnings,
1303
+ resolvedDeps: resolved, projectInfo, warnings: reportWarnings, parsedManifests,
1269
1304
  htmlPath: out.html, docPath: out.doc,
1270
1305
  });
1271
1306
  if (htmlPath) wrote.push(["HTML report", htmlPath]);
@@ -1311,7 +1346,9 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1311
1346
  ui.section("Output");
1312
1347
  for (const [label, p] of wrote) ui.ok(`${label} → ${chalk.white(p)}`);
1313
1348
  } else if (options.report === false) {
1314
- ui.info(chalk.dim("--no-report: no files written (scan + gate only)"));
1349
+ ui.info(chalk.dim(options.importAnonymized
1350
+ ? "import-anonymized: caches warmed, no report written (pair with --export-cache, then report offline from the real source tree)"
1351
+ : "--no-report: no files written (scan + gate only)"));
1315
1352
  }
1316
1353
  console.log();
1317
1354
 
@@ -50,8 +50,10 @@ module.exports = {
50
50
  const { ignoreTest, deps2Exclude } = opts;
51
51
  const out = new Map();
52
52
  const warnings = [];
53
+ const parsedManifests = [];
53
54
  for (const g of findComposerManifests(dir, dirFilter(dir, opts))) {
54
55
  if (g.composerLock) {
56
+ parsedManifests.push(g.composerLock);
55
57
  let parsed;
56
58
  try { parsed = parseComposerLock(g.composerLock); }
57
59
  catch (e) { warnings.push({ type: "parse-error", manifestPath: g.composerLock, message: `composer.lock parse failed: ${e.message}` }); continue; }
@@ -64,6 +66,7 @@ module.exports = {
64
66
  }
65
67
  } else if (g.composerJson) {
66
68
  // No lockfile → best-effort: pinned exact versions only + warning.
69
+ parsedManifests.push(g.composerJson);
67
70
  let parsed;
68
71
  try { parsed = parseComposerJson(g.composerJson); }
69
72
  catch (e) { warnings.push({ type: "parse-error", manifestPath: g.composerJson, message: `composer.json parse failed: ${e.message}` }); continue; }
@@ -83,7 +86,7 @@ module.exports = {
83
86
  warnings.push({ type: "no-lockfile", manifestPath: g.composerJson, message: `composer.json without composer.lock — best-effort: ${pinned} pinned, ${ranges} range(s) skipped (run "composer install")` });
84
87
  }
85
88
  }
86
- return { deps: out, warnings };
89
+ return { deps: out, warnings, parsedManifests };
87
90
  },
88
91
 
89
92
  coordKey(d) { return coordKeyFor("composer", d.namespace || "", d.name); },
@@ -44,6 +44,7 @@ module.exports = {
44
44
  const { deps2Exclude } = opts;
45
45
  const out = new Map();
46
46
  const warnings = [];
47
+ const parsedManifests = [];
47
48
  const add = (d, manifestPath) => {
48
49
  if (deps2Exclude && deps2Exclude.test(d.name)) return;
49
50
  out.set(coordKeyFor("go", "", d.name), makeDepRecord({ ecosystem: "go", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: false }));
@@ -51,6 +52,7 @@ module.exports = {
51
52
  for (const g of findGoDirs(dir, dirFilter(dir, opts))) {
52
53
  if (g.names.has("go.mod")) {
53
54
  const fp = path.join(g.dir, "go.mod");
55
+ parsedManifests.push(fp);
54
56
  try {
55
57
  const r = G.parseGoModFile(fp);
56
58
  if (r.deps.length) { for (const d of r.deps) add(d, fp); continue; }
@@ -59,11 +61,12 @@ module.exports = {
59
61
  }
60
62
  if (g.names.has("go.sum")) {
61
63
  const fp = path.join(g.dir, "go.sum");
64
+ parsedManifests.push(fp);
62
65
  try { const r = G.parseGoSumFile(fp); for (const d of r.deps) add(d, fp); }
63
66
  catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `go.sum parse failed: ${e.message}` }); }
64
67
  }
65
68
  }
66
- return { deps: out, warnings };
69
+ return { deps: out, warnings, parsedManifests };
67
70
  },
68
71
 
69
72
  coordKey(d) { return coordKeyFor("go", "", d.name); },
@@ -0,0 +1,91 @@
1
+ /**
2
+ * lib/codecs/gradle/catalog.js — Gradle version catalog (gradle/libs.versions.toml).
3
+ *
4
+ * Parses the TOML [versions]/[libraries] tables into a normalized shape and resolves
5
+ * the Kotlin/Groovy DSL type-safe accessors (`libs.foo.bar` → alias `foo-bar`) plus the
6
+ * `findVersion(ref)`/`findLibrary(alias)` forms used inside convention plugins.
7
+ *
8
+ * A Gradle accessor normalizes `-`, `_` and `.` to the same separator, so `groovy-core`,
9
+ * `groovy.core` and `groovy_core` are the same library. We normalize both the alias keys
10
+ * and the looked-up accessor with `[-_.] → "."` so the reverse lookup is unambiguous.
11
+ *
12
+ * @author: N.BRAUN
13
+ * @email: pp9ping@gmail.com
14
+ */
15
+ const TOML = require("smol-toml");
16
+
17
+ function normKey(s) { return String(s || "").replace(/[-_.]/g, "."); }
18
+
19
+ // A library entry's version may be an inline string, a { ref = "alias" } pointer into
20
+ // [versions], or absent (BOM-managed). Return the concrete string or null.
21
+ function resolveLibVersion(versionField, versions) {
22
+ if (versionField == null) return null;
23
+ if (typeof versionField === "string") return versionField || null;
24
+ if (typeof versionField === "object") {
25
+ if (typeof versionField.ref === "string") return versions[versionField.ref] || null;
26
+ // { strictly = "x" } / { require = "x" } / { prefer = "x" } rich version constraints.
27
+ return versionField.strictly || versionField.require || versionField.prefer || null;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * Parse a libs.versions.toml text → { versions: {alias→str}, libraries: {alias→{group,name,version}},
34
+ * plugins: {alias→{id,version}}, _byAccessor: {normalizedAlias→libraryEntry} }.
35
+ */
36
+ function parseVersionCatalog(text) {
37
+ let data = {};
38
+ try { data = TOML.parse(String(text || "")); } catch { data = {}; }
39
+ const versions = {};
40
+ for (const [k, v] of Object.entries(data.versions || {})) {
41
+ versions[k] = typeof v === "string" ? v : resolveLibVersion(v, {});
42
+ }
43
+ const libraries = {};
44
+ for (const [alias, raw] of Object.entries(data.libraries || {})) {
45
+ let group = null, name = null;
46
+ if (typeof raw === "string") {
47
+ // shorthand "group:name:version"
48
+ const parts = raw.split(":");
49
+ group = parts[0] || null; name = parts[1] || null;
50
+ libraries[alias] = { group, name, version: parts[2] || null };
51
+ continue;
52
+ }
53
+ if (raw && typeof raw === "object") {
54
+ if (typeof raw.module === "string") {
55
+ const i = raw.module.indexOf(":");
56
+ group = i >= 0 ? raw.module.slice(0, i) : raw.module;
57
+ name = i >= 0 ? raw.module.slice(i + 1) : null;
58
+ } else {
59
+ group = raw.group || null;
60
+ name = raw.name || null;
61
+ }
62
+ libraries[alias] = { group, name, version: resolveLibVersion(raw.version, versions) };
63
+ }
64
+ }
65
+ const plugins = {};
66
+ for (const [alias, raw] of Object.entries(data.plugins || {})) {
67
+ if (typeof raw === "string") { const [id, version] = raw.split(":"); plugins[alias] = { id: id || null, version: version || null }; continue; }
68
+ if (raw && typeof raw === "object") plugins[alias] = { id: raw.id || null, version: resolveLibVersion(raw.version, versions) };
69
+ }
70
+ const byAccessor = {};
71
+ for (const [alias, entry] of Object.entries(libraries)) byAccessor[normKey(alias)] = entry;
72
+ return { versions, libraries, plugins, _byAccessor: byAccessor };
73
+ }
74
+
75
+ /** Resolve a DSL accessor (the part after `libs.`, e.g. "clamav.client") → library entry or null. */
76
+ function resolveLibraryAccessor(catalog, accessor) {
77
+ if (!catalog) return null;
78
+ return catalog._byAccessor[normKey(accessor)] || null;
79
+ }
80
+
81
+ /** Resolve a version alias (libs.findVersion("spring-boot")) → string or null. */
82
+ function findCatalogVersion(catalog, ref) {
83
+ if (!catalog || !ref) return null;
84
+ if (catalog.versions[ref] != null) return catalog.versions[ref];
85
+ // tolerate accessor-style refs
86
+ const want = normKey(ref);
87
+ for (const [k, v] of Object.entries(catalog.versions)) if (normKey(k) === want) return v;
88
+ return null;
89
+ }
90
+
91
+ module.exports = { parseVersionCatalog, resolveLibraryAccessor, findCatalogVersion, normKey };
@@ -0,0 +1,211 @@
1
+ /**
2
+ * lib/codecs/gradle/parse.js — Gradle manifest parsers (lockfile-first, best-effort DSL).
3
+ *
4
+ * gradle.lockfile → authoritative `g:a:v=conf,conf` (resolved, transitives incl.)
5
+ * gradle.properties → `key=value` (used to resolve `$var` versions)
6
+ * build.gradle / .kts → best-effort regex over `dependencies { … }`:
7
+ * string notation, map notation, version-catalog accessors
8
+ * (`libs.foo.bar`), and `platform(...)` BOMs (surfaced
9
+ * separately for the import-BOM backfill, NOT as a dep).
10
+ *
11
+ * A Gradle dependency IS a Maven coordinate, so the codec emits ecosystem "maven" records;
12
+ * this module only turns the various Gradle surfaces into {group,name,version} tuples.
13
+ * Versions that can't be resolved statically (programmatic constructs, missing var) come
14
+ * back null — never assumed-vulnerable — and are listed in `unresolved` for a warning.
15
+ *
16
+ * @author: N.BRAUN
17
+ * @email: pp9ping@gmail.com
18
+ */
19
+ const { resolveLibraryAccessor, findCatalogVersion } = require("./catalog");
20
+
21
+ const COORD = "[A-Za-z0-9_.\\-]+";
22
+
23
+ // Function/keyword call sites that look like a configuration but are NOT external deps.
24
+ const DENY = new Set([
25
+ "id", "kotlin", "version", "project", "exclude", "platform", "enforcedPlatform",
26
+ "files", "fileTree", "gradleApi", "localGroovy", "because", "create", "named",
27
+ "register", "maven", "url", "uri", "from", "into", "extendsFrom", "add", "plugin",
28
+ "apply", "alias", "set", "property", "the", "get", "named", "dependencies",
29
+ ]);
30
+
31
+ function isTestConfig(c) { return /test/i.test(String(c || "")); }
32
+
33
+ // Guard against non-coordinate string args that happen to contain a colon — e.g.
34
+ // jvmArgs("-Xshare:off"), systemProperty("a:b"). A real Maven groupId is reverse-DNS:
35
+ // it always starts with a letter (never "-" or a digit); an artifactId starts alphanumeric.
36
+ function looksLikeCoord(group, name) {
37
+ return /^[A-Za-z][\w.\-]*$/.test(String(group || "")) && /^[A-Za-z0-9][\w.\-]*$/.test(String(name || ""));
38
+ }
39
+
40
+ function depScope(config) {
41
+ const isDev = isTestConfig(config);
42
+ return { scope: isDev ? "test" : "compile", isDev };
43
+ }
44
+
45
+ // Read the version expression that begins at s[i] (right after `g:a:` in a coord string).
46
+ // Handles a `${ … }` template (balanced braces — so a nested findVersion("x") with its own
47
+ // quotes/parens is captured whole), a bare `$var`, or a plain literal up to the closing quote.
48
+ function readVersionExpr(s, i) {
49
+ if (s[i] === "$" && s[i + 1] === "{") {
50
+ let depth = 0;
51
+ for (let j = i + 1; j < s.length; j++) {
52
+ if (s[j] === "{") depth++;
53
+ else if (s[j] === "}" && --depth === 0) return s.slice(i, j + 1);
54
+ }
55
+ return s.slice(i);
56
+ }
57
+ if (s[i] === "$") { const m = s.slice(i).match(/^\$[\w.]+/); return m ? m[0] : null; }
58
+ const m = s.slice(i).match(/^[^"'\s)]+/);
59
+ return m ? m[0] : null;
60
+ }
61
+
62
+ /** Resolve a (possibly `$var` / catalog-backed) version expression → concrete string or null. */
63
+ function resolveVer(raw, ctx) {
64
+ if (raw == null) return null;
65
+ const v = String(raw).trim();
66
+ if (!v) return null;
67
+ if (v.includes("$")) {
68
+ let m = v.match(/findVersion\(\s*["']([^"']+)["']\s*\)/);
69
+ if (m) return findCatalogVersion(ctx.catalog, m[1]) || null;
70
+ m = v.match(/libs\.versions\.([\w.]+?)(?:\.get\(\))?[\s}"')]*$/);
71
+ if (m) { const r = findCatalogVersion(ctx.catalog, m[1]); if (r) return r; }
72
+ m = v.match(/\$\{?([\w.]+)\}?/);
73
+ if (m) {
74
+ const k = m[1];
75
+ if (ctx.localVars && ctx.localVars[k] != null) return ctx.localVars[k];
76
+ if (ctx.properties && ctx.properties[k] != null) return ctx.properties[k];
77
+ }
78
+ return null;
79
+ }
80
+ const m = v.match(/[\w][\w.\-]*/);
81
+ return m ? m[0] : null;
82
+ }
83
+
84
+ /** Parse the inner content of a `platform( … )` call → {group,name,version} or null. */
85
+ function parsePlatformCoord(content, ctx) {
86
+ const c = String(content || "").trim();
87
+ const head = c.match(new RegExp(`["']?\\s*(${COORD}):(${COORD}):`));
88
+ if (head) {
89
+ const tail = c.slice(c.indexOf(head[0]) + head[0].length);
90
+ return { group: head[1], name: head[2], version: resolveVer(tail, ctx) || null };
91
+ }
92
+ const ga = c.match(new RegExp(`["']\\s*(${COORD}):(${COORD})\\s*["']`));
93
+ if (ga) return { group: ga[1], name: ga[2], version: null };
94
+ const lib = c.match(/libs\.([\w.]+)/);
95
+ if (lib && ctx.catalog) {
96
+ const e = resolveLibraryAccessor(ctx.catalog, lib[1]);
97
+ if (e && e.group && e.name) return { group: e.group, name: e.name, version: e.version || null };
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /** Parse a gradle.lockfile → { deps: [{group,name,version,scope,isDev,configurations}] }. */
103
+ function parseGradleLockfile(text) {
104
+ const deps = [];
105
+ for (const raw of String(text || "").split(/\r?\n/)) {
106
+ const line = raw.trim();
107
+ if (!line || line.startsWith("#")) continue;
108
+ const eq = line.indexOf("=");
109
+ if (eq < 0) continue;
110
+ const coord = line.slice(0, eq).trim();
111
+ if (coord === "empty") continue;
112
+ const configurations = line.slice(eq + 1).split(",").map(s => s.trim()).filter(Boolean);
113
+ const parts = coord.split(":");
114
+ if (parts.length < 3 || !parts[0] || !parts[1] || !parts[2]) continue;
115
+ const isDev = configurations.length > 0 && configurations.every(isTestConfig);
116
+ deps.push({ group: parts[0], name: parts[1], version: parts[2], scope: isDev ? "test" : "compile", isDev, configurations });
117
+ }
118
+ return { deps };
119
+ }
120
+
121
+ /** Parse a gradle.properties → { key: value }. */
122
+ function parseGradleProperties(text) {
123
+ const out = {};
124
+ for (const raw of String(text || "").split(/\r?\n/)) {
125
+ const line = raw.trim();
126
+ if (!line || line.startsWith("#") || line.startsWith("!")) continue;
127
+ const eq = line.indexOf("=");
128
+ if (eq < 0) continue;
129
+ const k = line.slice(0, eq).trim();
130
+ if (k) out[k] = line.slice(eq + 1).trim();
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Best-effort parse of a build.gradle / build.gradle.kts.
137
+ * @param opts { catalog, properties, kotlin } — catalog from parseVersionCatalog, properties
138
+ * from parseGradleProperties; `kotlin` is informational (the regexes accept both DSLs).
139
+ * @returns { deps: [{group,name,version,configuration,scope,isDev}], platformBoms, unresolved }
140
+ */
141
+ function parseBuildScript(text, opts = {}) {
142
+ const src = String(text || "");
143
+ const ctx = { catalog: opts.catalog || null, properties: opts.properties || {}, localVars: {} };
144
+ for (const m of src.matchAll(/\b(?:val|def)\s+(\w+)\s*=\s*["']([^"']*)["']/g)) ctx.localVars[m[1]] = m[2];
145
+
146
+ const deps = [];
147
+ const platformBoms = [];
148
+ const unresolved = [];
149
+ const seen = new Set();
150
+ const addDep = (group, name, versionRaw, config) => {
151
+ if (!group || !name || !looksLikeCoord(group, name)) return;
152
+ const key = `${group}:${name}`;
153
+ if (seen.has(key)) return;
154
+ seen.add(key);
155
+ const version = resolveVer(versionRaw, ctx) || null;
156
+ if (versionRaw != null && /\$/.test(String(versionRaw)) && !version) unresolved.push({ group, name, raw: String(versionRaw), reason: "unresolved-variable" });
157
+ deps.push({ group, name, version, configuration: config, ...depScope(config) });
158
+ };
159
+
160
+ // 1. platform()/enforcedPlatform() — balanced-paren scan (handles nested ${...} templates
161
+ // with their own quotes/parens), then blank the span so the inner coord isn't re-read.
162
+ const platSpans = [];
163
+ const re = /(?:enforced)?[Pp]latform\s*\(/g;
164
+ let pm;
165
+ while ((pm = re.exec(src))) {
166
+ const open = src.indexOf("(", pm.index + pm[0].length - 1);
167
+ if (open < 0) continue;
168
+ let depth = 0, end = -1;
169
+ for (let i = open; i < src.length; i++) {
170
+ if (src[i] === "(") depth++;
171
+ else if (src[i] === ")") { if (--depth === 0) { end = i; break; } }
172
+ }
173
+ if (end <= open) continue;
174
+ const coord = parsePlatformCoord(src.slice(open + 1, end), ctx);
175
+ if (coord && coord.group && coord.name && !platformBoms.some(b => b.group === coord.group && b.name === coord.name)) platformBoms.push(coord);
176
+ platSpans.push([pm.index, end + 1]);
177
+ }
178
+ let work = src;
179
+ if (platSpans.length) {
180
+ let out = "", last = 0;
181
+ for (const [s, e] of platSpans) { out += src.slice(last, s) + " ".repeat(e - s); last = e; }
182
+ work = out + src.slice(last);
183
+ }
184
+
185
+ // 2. map notation: conf group: 'x', name: 'y'[, version: 'z']
186
+ for (const m of work.matchAll(/\b(\w+)\s*\(?\s*group:\s*(["'])([^"']+)\2\s*,\s*name:\s*(["'])([^"']+)\4(?:\s*,\s*version:\s*(["'])([^"']+)\6)?/g)) {
187
+ if (DENY.has(m[1])) continue;
188
+ addDep(m[3], m[5], m[7] || null, m[1]);
189
+ }
190
+ // 3. string notation: conf("g:a[:v]") | conf 'g:a[:v]'. We match only the opening
191
+ // `conf("g:a` + optional `:` and then read the version expression manually, so a
192
+ // version like `${libs.findVersion("x").get()}` (nested quotes) isn't truncated.
193
+ const strRe = new RegExp(`\\b(\\w+)\\s*(?:\\(\\s*)?(["'])(${COORD}):(${COORD})(:?)`, "g");
194
+ let sm;
195
+ while ((sm = strRe.exec(work))) {
196
+ if (DENY.has(sm[1])) continue;
197
+ const versionRaw = sm[5] === ":" ? readVersionExpr(work, sm.index + sm[0].length) : null;
198
+ addDep(sm[3], sm[4], versionRaw, sm[1]);
199
+ }
200
+ // 4. version-catalog accessor: conf(libs.foo.bar)
201
+ for (const m of work.matchAll(/\b(\w+)\s*(?:\(\s*)?libs\.([\w.]+)/g)) {
202
+ if (DENY.has(m[1]) || m[1] === "libs") continue;
203
+ const accessor = m[2];
204
+ if (/^(versions|findVersion|bundles|plugins)\b/.test(accessor)) continue;
205
+ const lib = resolveLibraryAccessor(ctx.catalog, accessor);
206
+ if (lib && lib.group && lib.name) addDep(lib.group, lib.name, lib.version, m[1]);
207
+ }
208
+ return { deps, platformBoms, unresolved };
209
+ }
210
+
211
+ module.exports = { parseGradleLockfile, parseGradleProperties, parseBuildScript, resolveVer, depScope, isTestConfig };