fad-checker 2.2.0 → 2.2.2

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
@@ -9,9 +9,10 @@
9
9
 
10
10
  `fad-checker` scans **Maven**, **npm**, **Yarn**, **Composer (PHP)**, **PyPI (Python)**, **NuGet (C#/.NET)**, **Go**, **Ruby**, **vendored JavaScript** and **committed native binaries** in any source tree — multi-module, monorepo, polyglot, whatever you've got — and produces a single self-contained HTML report with CVE (prioritised by **EPSS + CISA KEV**), EOL, obsolete, outdated and **license** findings, plus per-ecosystem fix recipes. It also exports a **CycloneDX 1.6 SBOM** and a **CSAF 2.0 VEX**.
11
11
 
12
+
12
13
  🌐 **[Project site & docs →](https://n8tz.github.io/fad-checker/)**
13
14
 
14
- <p align="center"><img src="docs/assets/cli.png" alt="fad-checker terminal output — a [n/N] checklist warming each vulnerability database, then CVE findings coloured by severity" width="760"></p>
15
+ <p align="center"><img src="docs/assets/cli.png" alt="fad-checker terminal output — a [n/N] checklist warming each vulnerability database, then CVE findings coloured by severity" height="760"></p>
15
16
 
16
17
  It runs against the source files alone. **No `mvn`, no `npm install`, no `composer install`, no `pip`, no `dotnet restore`, no Docker.** It reads `pom.xml`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `composer.lock`, `poetry.lock`/`Pipfile.lock`/`uv.lock`/`pdm.lock`/`pyproject.toml`/`requirements.txt`, and `packages.lock.json`/`*.csproj`/`*.fsproj`/`*.vbproj`/`packages.config` directly.
17
18
 
@@ -55,7 +56,7 @@ Exactly **one** runtime dependencies must be on PATH (or installed automatically
55
56
  | **4. EOL frameworks** | endoflife.date | Spring Boot 2.5, Hibernate 4.x, EOL JDKs, AngularJS, Laravel/Symfony, Django, .NET, etc. |
56
57
  | **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` |
57
58
  | **6. Outdated libraries** | Maven Central + npm / Packagist / PyPI / NuGet registries | Available newer versions, with release dates |
58
- | **7. 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 |
59
+ | **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 |
59
60
  | **8. Fix Recommendations** | computed | Per-ecosystem pin recipes: Maven `<dependencyManagement>`, npm `overrides`, yarn `resolutions`, `composer require`, `pip install`, `dotnet add package` |
60
61
 
61
62
  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.
@@ -104,6 +105,9 @@ fad-checker -s ./proj --no-all-libs --no-transitive
104
105
  # Fully offline (uses cached data only)
105
106
  fad-checker -s ./proj --offline
106
107
 
108
+ # License + copyleft-policy chapter (off by default)
109
+ fad-checker -s ./proj --licenses
110
+
107
111
  # Pick ecosystems — --ecosystem is a list: auto (default) | all | comma list
108
112
  fad-checker -s ./proj --ecosystem maven # Maven only
109
113
  fad-checker -s ./proj --ecosystem maven,npm,pypi # several
@@ -127,36 +131,22 @@ Run `fad-checker --help` for the full flag list.
127
131
  The console prints a summary; the full detail lives in the self-contained HTML/`.doc`:
128
132
 
129
133
  ```
134
+ ╭───────────────────────────────────────────────────╮
135
+ │ fad-checker v2.2.1 · Autonomous Dependency Checker │
136
+ ╰───────────────────────────────────────────────────╯
130
137
  Executive Summary [CRITICAL] — 1708 dependencies scanned
131
138
  • 81 CVE in production deps (critical=5, high=53, medium=12, low=11)
132
- • 32 CVE in dev/test deps
133
- 17 vulnerable vendored JS finding(s) (retire.js)
134
- • 2 end-of-life frameworks
135
- • 13 obsolete / deprecated libs
136
- • 172 outdated libs
139
+ • 32 CVE in dev/test deps · 17 vulnerable vendored JS (retire.js)
140
+ 2 EOL frameworks · 13 obsolete/deprecated · 172 outdated
137
141
  • 4 scan-completeness alerts — see chapter 0
138
-
139
- 0. Warnings & scan-completeness (4)
140
- 1. CVE Vulnerabilities — production (81)
141
- 1.a Maven (49)
142
- 1.a.0 All (49)
143
- By pom.xml (14 files)
144
- build/building/pom.xml (17)
145
- services/api/pom.xml (17)
146
- … 12 more
147
- 1.b npm (package-lock) (32)
148
- 1.b.0 All (32)
149
- By package-lock.json (1 file)
150
- web/package-lock.json (32)
151
- 2. CVE in dev dependencies (32)
152
- 3. Vendored JS scan — retire.js (17)
153
- 4. End-of-Life Frameworks (2)
154
- 5. Obsolete / Deprecated Libraries (13)
155
- 6. Outdated Libraries (172)
156
- 7. Fix Recommendations
157
142
  ```
158
143
 
159
- Each CVE row shows: severity badge · CVE / GHSA id · dep coord & version · which manifest file declares it · source(s) (CVEProject / OSV / NVD / Snyk / retire / fad) · fix-version · summary. Click a row for the full panel (CVSS vectors, NVD references categorised by type, transitive paths, CPE configurations).
144
+ The HTML report organises this into chapters (0 Warnings, 1 CVE prod, 1B embedded JARs,
145
+ 1C native binaries, 1D vendored-JS inventory, 2 vendored-JS vulns, 3 dev CVE, 4 EOL,
146
+ 5 obsolete, 6 outdated, 7 Licenses *(opt-in)*, 8 Fix Recommendations), each grouped by
147
+ ecosystem and manifest. Every CVE row carries: severity badge · CVE/GHSA id · coord &
148
+ version · declaring manifest · source(s) · fix-version · priority (KEV/EPSS) — click for
149
+ the full panel (CVSS vectors, references, transitive paths, CPE configs).
160
150
 
161
151
  ---
162
152
 
@@ -209,54 +199,17 @@ fad-checker --completion zsh > ~/.zsh/completions/_fad-checker
209
199
  | Ruby | `Gemfile.lock` (`specs:`) | the lockfile |
210
200
  | Vendored JS / binaries | the committed `.js` / `.jar` / `.so` files themselves | n/a (read in place) |
211
201
 
212
- The rest of this section is the detail behind that table.
213
-
214
- - **Maven** `pom.xml` files are parsed with xml2js. Property substitution (`${jackson.version}`), parent inheritance, local BOM imports (`<scope>import</scope>`) and every profile are resolved in-process. Transitive deps are walked by fetching child POMs from Maven Central (cached forever — POMs are immutable). When the project uses an **external BOM** (`spring-boot-dependencies` etc.), the deps whose version comes from that BOM can't be resolved without `mvn` itself — those are surfaced in chapter 0 as "unresolved-versions" so you know what's missing.
215
- - **npm / Yarn / pnpm** — `package-lock.json` (v1, v2, v3), `yarn.lock` (v1 + Berry/v2+, via `js-yaml`) and `pnpm-lock.yaml` (v5/v6/v9, via `js-yaml`) are parsed directly. Lockfiles already contain every transitive version. No `node_modules/` traversal, no `npm install`.
216
- - **Composer (PHP)** — `composer.lock` (`packages` + `packages-dev`) gives concrete + transitive versions; `composer.json` alone is best-effort.
217
- - **PyPI (Python)** — `poetry.lock` / `Pipfile.lock` / `uv.lock` / `pdm.lock` are parsed (TOML via `smol-toml`, or JSON); `pyproject.toml` (PEP 621 `[project]` + `[tool.poetry]`) and `requirements.txt` (following `-r`/`-c` includes recursively, with `-c` constraint pins applied to ranges) are best-effort on exact pins. Package names are PEP 503-normalised (`Flask-SQLAlchemy` → `flask-sqlalchemy`).
218
- - **NuGet (C#/.NET)** — `packages.lock.json` is authoritative; otherwise `*.csproj` / `*.fsproj` / `*.vbproj` `<PackageReference>` (resolving Central Package Management against `Directory.Packages.props`) and legacy `packages.config`. Ids are case-insensitive.
219
- - **Go** — `go.mod` `require` entries are the selected versions (full pruned graph on Go ≥1.17; `// indirect` → transitive), `go.sum` as fallback. OSV "Go" ecosystem for recall, the Go module proxy for outdated.
220
- - **Ruby** — `Gemfile.lock` `specs:` give the resolved gem set. OSV "RubyGems" for recall, the RubyGems API for outdated + licenses.
221
- - **Lockfile-first, best-effort fallback** — when a lockfile is present it wins. When it's absent, the loose manifest (`package.json` / `composer.json` / `pyproject.toml` / `requirements.txt` / `*.csproj`) is still parsed for its **pinned exact versions**, with ranges skipped and a `no-lockfile` warning in chapter 0 flagging the partial coverage.
222
- - **Vendored JavaScript** — `retire.js` shells out (with `--verbose`) and scans `.js` / `.min.js` files by signature. Two outputs: a full **inventory of every identified standalone lib** — jQuery, Bootstrap, Angular, PDF.js copies that no lockfile knows about, *vulnerable or not* (chapter 1D, a cyber-hygiene constat on unmanaged third-party code; `--no-vendored-js-inventory` to skip) — and the **vulnerable subset** with its CVEs/advisories (chapter 2).
223
- - **Embedded JARs** — committed `.jar` / `.war` / `.ear` archives are unzipped **in memory** (via `fflate` — nested fat-jar libs are recursed without ever touching disk, so there's no zip-slip risk) and each artifact's Maven coordinate is read from `META-INF/maven/.../pom.properties` (authoritative), then `MANIFEST.MF`, then the file name. Those coordinates run through the same CVE/OSV/NVD matching as declared deps but report in their own **Embedded binaries** chapter, grouped by containing archive. An archive whose coordinate can't be resolved is flagged in chapter 0 rather than scanned blindly. Auto when archives are present; disable with `--no-jars`. (Embedded coords don't trigger Maven Central transitive resolution — a fat-jar already ships its dependencies, which the recursion finds directly.)
224
- - **Committed native binaries** — `.dll` / `.exe` / `.so` / `.dylib` files are found by a magic-byte-confirmed walk (extension **and** PE/ELF/Mach-O signature must agree, so an image renamed `.so` or any asset is rejected), hashed (SHA-1 + SHA-256), then **identified by checksum** online: **deps.dev** query-by-hash returns the exact package coordinate (so the file is byte-identical to a published artifact → *pristine*, and ought to be a declared dependency), and **CIRCL hashlookup** recognises known OS/distro/CDN/NSRL files (→ *known-good*) plus a free `KnownMalicious` flag. Files no source knows are flagged *unknown*; a filename that disagrees with the resolved identity is flagged *name≠checksum*. Reported in their own **Unmanaged / vendored binaries** chapter (1C) and the JSON export. Cached + `--offline`-aware; disable with `--no-binaries`. No malware/AV lane and no binary-metadata parsing — identity is hash-lookup, integrity is hash-comparison.
225
- - **CVE data** — three independent sources merged:
226
- - **CVEProject** (the canonical `cvelistV5` bundle, filtered to Maven-relevant entries)
227
- - **OSV.dev** (Google + GitHub Security Lab, multi-ecosystem)
228
- - **NVD** (official NIST records, used for enrichment: full CVSS, references, CPE configurations)
229
- - **CPE refinement** — once a CVE is matched, its NVD CPE configurations are checked against the dep version range. A match outside the vulnerable range is flagged `cpeFiltered: true` (likely false positive). A curated `data/cpe-coord-map.json` maps CPE `vendor:product` to Maven `g:a` (60+ entries seeded: log4j, jackson, spring, tomcat, jetty, netty, …).
230
- - **Prioritization** — each matched CVE is enriched with **EPSS** (FIRST.org exploit-prediction percentile) and **CISA KEV** (known-exploited catalogue), then scored: KEV (exploited in the wild) outranks EPSS-weighted CVSS. The report sorts by this composite priority and badges KEV/EPSS.
231
- - **Licenses** — each dependency's license is resolved (registry metadata, no extra request; Maven from cached POMs), normalised to SPDX and classified against a copyleft policy (`data/license-policy.json`) — permissive / weak / strong / network copyleft / proprietary / unknown.
232
- - **Unified outputs** — one `--report-<type>` flag per output, each with an OPTIONAL path (omit it → a default name under `--report-output`): `--report-html`, `--report-doc`, plus the machine-readable `--report-sbom` (**CycloneDX 1.6**, vulnerabilities inline / VDR), `--report-csaf` (**CSAF 2.0 VEX**), `--report-json` (flat findings, diff-friendly) and `--report-sarif` (**SARIF 2.1.0** for GitHub/GitLab code scanning). With no `--report-*` flag, HTML + `.doc` are written by default; `--no-report` writes nothing (gate-only). purls per ecosystem.
233
- - **CI gating & triage** — `--fail-on <low|medium|high|critical|kev>` sets a non-zero exit code (`kev` = fail only on a CISA-known-exploited finding). `--ignore <file>` (CVE/coord/glob rules) and `--vex <file>` (ingest a CSAF VEX) suppress accepted-risk / false-positive findings from the report and the gate, while keeping them flagged in the exports — so re-audits stay signal-rich.
202
+ Highlights of the matching layer: **three CVE sources merged** (CVEProject + OSV.dev + NVD), **CPE/version cross-check** to drop false positives, **EPSS + CISA KEV** prioritisation, lockfile-first with a **best-effort pinned-version fallback** when no lockfile, in-memory **embedded-JAR** unzip (no disk, no zip-slip), and **checksum identity** for native binaries. Outputs: HTML + `.doc` by default, plus opt-in `--report-sbom`/`-csaf`/`-json`/`-sarif`; CI gating via `--fail-on` with `--ignore`/`--vex` triage.
203
+
204
+ > Full per-ecosystem and per-stage detail lives in **[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)** and **[`docs/USAGE.md`](docs/USAGE.md)**.
234
205
 
235
206
  ---
236
207
 
237
208
  ## Caching
238
209
 
239
- All cached data lives in `~/.fad-checker/`:
240
-
241
- | Cache | Path | TTL |
242
- | --- | --- | --- |
243
- | Maven CVE index (CVEProject bundle, filtered) | `cve-data/maven-cve-index.json` | 24 h |
244
- | OSV per-dep lookups | `osv-cache/<ecosystem>__<g>__<a>__<v>.json` | 12 h |
245
- | OSV vuln details | `osv-cache/vuln_<id>.json` | 12 h |
246
- | NVD CVE records | `nvd-cache/<cveId>.json` | 7 d |
247
- | EPSS scores (FIRST.org) | `epss-cache.json` | 24 h |
248
- | CISA KEV catalogue | `kev-cache.json` | 24 h |
249
- | Go module proxy (latest) | `go-proxy-cache.json` | 24 h |
250
- | RubyGems (latest + licenses) | `rubygems-cache.json` | 24 h |
251
- | Binary identity (deps.dev + CIRCL by hash) | `hash-id-cache.json` | 24 h |
252
- | endoflife.date cycles | `eol-cache.json` | 7 d |
253
- | Maven Central latest versions | `version-cache.json` | 24 h |
254
- | Transitive POMs from Maven Central | `poms-cache/<g>__<a>__<v>.pom` | ∞ (immutable) |
255
- | retire.js findings | `retire-cache/<md5(src)>.json` | 24 h |
256
- | retire.js signature DB | `retire-signatures/jsrepository-v5.json` | warmed online, used offline |
257
- | User config (NVD key) | `config.json` (mode 0600) | — |
258
-
259
- Export the lot to share between machines:
210
+ All cached data lives in `~/.fad-checker/` — every source is hit at most once per its
211
+ TTL (12 h–7 d, transitive POMs forever as they're immutable). Full per-cache TTL table
212
+ in [`docs/USAGE.md`](docs/USAGE.md). Export the lot to share between machines:
260
213
 
261
214
  ```bash
262
215
  fad-checker --export-cache fad-cache.tar.gz
@@ -317,66 +270,28 @@ DB is warmed online (phase 2) and carried by `--export-cache`.
317
270
 
318
271
  ## Custom repositories & registries
319
272
 
320
- Out of the box `fad-checker` queries each ecosystem's **public** registry (Maven Central, registry.npmjs.org, PyPI, RubyGems, the Go module proxy). If your project pulls artifacts from a private **Nexus / Artifactory / JBoss** (Maven), **Verdaccio / GitHub Packages** (npm), **devpi / Artifactory** (PyPI), **Gemfury / Geminabox** (Ruby) or a private **GOPROXY / Athens** (Go), register them so transitive resolution, outdated checks, deprecation and license lookups work end-to-end.
321
-
322
- Custom registries for **`maven`, `npm`, `pypi`, `ruby`, `go`**:
273
+ Register private **Nexus / Artifactory / Verdaccio / devpi / GOPROXY** registries (for **`maven`, `npm`, `pypi`, `ruby`, `go`**) so transitive resolution, outdated/deprecation and license lookups reach them:
323
274
 
324
275
  ```bash
325
- # Persist a registry (lives in ~/.fad-checker/config.json, keyed by ecosystem)
326
- fad-checker --add-repo maven nexus https://nexus.acme.com/repository/maven-public/ --auth alice:s3cr3t
327
- fad-checker --add-repo npm verdaccio https://npm.acme.com/ --token "$NPM_TOKEN"
328
- fad-checker --add-repo pypi devpi https://pypi.acme.com/root/pypi/+simple/ --auth alice:s3cr3t
329
- fad-checker --list-repos # grouped by ecosystem
330
- fad-checker --remove-repo npm verdaccio
331
-
332
- # One-off (not persisted) — repeatable, always ecosystem-scoped as <eco>=<url>
333
- fad-checker -s ./proj --repo npm=https://npm.acme.com/ --repo maven=https://nexus.acme.com/repository/maven-public/
334
- # Inline auth in the URL also works:
335
- fad-checker -s ./proj --repo maven=https://alice:s3cr3t@nexus.acme.com/repository/maven-public/
276
+ fad-checker --add-repo maven nexus https://nexus.acme.com/repository/maven-public/ --auth alice:s3cr3t
277
+ fad-checker --add-repo npm verdaccio https://npm.acme.com/ --token "$NPM_TOKEN"
278
+ fad-checker -s ./proj --repo npm=https://npm.acme.com/ # one-off, repeatable
336
279
  ```
337
280
 
338
- Registries are tried **in declared order, the public registry last**. Auth is `--auth user:pass` `Basic <base64>` or `--token TOK` `Bearer TOK` (inline `https://user:pass@host/` also works). Responses are cached per coordinate, so subsequent runs are free even against a private registry.
339
-
340
- > **PyPI / Ruby caveat:** the custom base must expose the **same JSON API** as the public one (`<base>/<pkg>/json`, `<base>/<gem>.json`). A pure PEP 503 *simple-index* mirror that only lists files won't yield latest/yanked/license metadata — point at the JSON-capable endpoint (Artifactory/devpi/Nexus all have one). **NuGet** and **Composer** private feeds aren't supported yet (their service-index / `packages.json` discovery is a separate follow-up).
281
+ Tried in declared order, public registry last; auth via `--auth user:pass` / `--token TOK` / inline URL; responses cached per coordinate. NuGet/Composer private feeds aren't supported yet. **Full details, the PyPI/Ruby JSON-API caveat and `--list-repos`/`--remove-repo` → [`docs/USAGE.md`](docs/USAGE.md).**
341
282
 
342
283
  ---
343
284
 
344
285
  ## Configuration file & environment
345
286
 
346
- Don't retype flags every run. `fad-checker` reads defaults from three places, **lowest priority first**, all overridable on the command line:
347
-
348
- | Layer | Where | Format |
349
- | --- | --- | --- |
350
- | **CLI flags** | the command line | flags (always win) |
351
- | **Config file** | `--config <file.json>`, else `./.fad-env.json` | **JSON** object, keys = option names |
352
- | **`FAD_CHECKER_ENV`** | environment variable | a **string of CLI flags** (what you'd type) |
353
- | **Global config** | `~/.fad-checker/config.json` | persisted NVD key + `registries` |
354
-
355
- Effective precedence: **CLI flag > config file > `FAD_CHECKER_ENV` > global config > built-in defaults.** A file/env value only fills an option you did *not* pass on the CLI; **registries are merged (unioned) across every layer**, never overridden.
356
-
357
- ```jsonc
358
- // ./.fad-env.json — JSON, keys mirror the CLI options (camelCase)
359
- {
360
- "source": "./my-project", // alias of --src (so is "src")
361
- "exclude": "^(com\\.acme|client)\\.",
362
- "excludePath": ["packages/legacy/**", "**/fixtures/**"],
363
- "failOn": "high",
364
- "noNuget": true,
365
- "registries": {
366
- "npm": [{ "name": "verdaccio", "url": "https://npm.acme.com/", "token": "…" }],
367
- "maven":[{ "name": "nexus", "url": "https://nexus.acme.com/repository/maven-public/", "auth": "user:pass" }]
368
- }
369
- }
370
- ```
287
+ Don't retype flags every run. Precedence: **CLI flag > `--config <file.json>` / `./.fad-env.json` (JSON, keys = camelCase options) > `FAD_CHECKER_ENV` (a string of CLI flags) > `~/.fad-checker/config.json` > defaults.** A file/env value only fills an option you didn't pass; **registries are merged across every layer**.
371
288
 
372
289
  ```bash
373
- fad-checker --config ./ci/fad-env.json # explicit file (beats ./.fad-env.json)
374
- FAD_CHECKER_ENV='--fail-on high --no-nuget --repo npm=https://npm.acme.com/' fad-checker -s ./proj
290
+ fad-checker --config ./ci/fad-env.json
291
+ FAD_CHECKER_ENV='--fail-on high --no-nuget' fad-checker -s ./proj
375
292
  ```
376
293
 
377
- The **source directory** accepts three spellings everywhere: `-s`, `--src`, `--source` (and the JSON key `"source"`/`"src"`).
378
-
379
- > **`-e/--exclude` vs `--exclude-path`:** `--exclude` is a regex on the **coordinate** (groupId/name) — it drops matching *dependencies*. `--exclude-path` is a gitignore-style glob on the **directory path** (relative to `--src`) — it prunes the *walk* so nothing under it is read. They compose. `--exclude-path` is repeatable, unioned across config layers, and `--no-default-excludes` lets you walk the normally-pruned dirs (`node_modules`, `vendor`, `target`, `.git`, …).
294
+ `-s` / `--src` / `--source` are aliases. **`-e/--exclude`** is a regex on the **coordinate** (drops deps); **`--exclude-path`** is a gitignore-style glob on the **path** (prunes the walk). Full `.fad-env.json` schema → [`docs/USAGE.md`](docs/USAGE.md).
380
295
 
381
296
  ---
382
297
 
@@ -6,7 +6,7 @@ _fad_check_complete() {
6
6
  COMPREPLY=()
7
7
  cur="${COMP_WORDS[COMP_CWORD]}"
8
8
  prev="${COMP_WORDS[COMP_CWORD-1]}"
9
- opts="--src --target --exclude --verbose --no-report --no-transitive --no-all-libs --no-osv --no-nvd --no-epss --no-kev --no-licenses --no-retire --retire-refresh --report-output --report-html --report-doc --report-sbom --report-csaf --report-json --report-sarif --fail-on --ignore --vex --ignore-test --cve-refresh --cve-offline --snyk --transitive-depth --offline --ecosystem --no-maven --no-npm --no-yarn --no-nuget --no-composer --no-pypi --no-go --no-ruby --no-jars --no-js --set-nvd-key --show-config --completion --help --version -s -t -e -v"
9
+ opts="--src --target --exclude --verbose --no-report --no-transitive --no-all-libs --no-osv --no-nvd --no-epss --no-kev --licenses --no-retire --retire-refresh --report-output --report-html --report-doc --report-sbom --report-csaf --report-json --report-sarif --fail-on --ignore --vex --ignore-test --cve-refresh --cve-offline --snyk --transitive-depth --offline --ecosystem --no-maven --no-npm --no-yarn --no-nuget --no-composer --no-pypi --no-go --no-ruby --no-jars --no-js --set-nvd-key --show-config --completion --help --version -s -t -e -v"
10
10
  case "$prev" in
11
11
  --src|-s|--target|-t|--report-output)
12
12
  COMPREPLY=( $(compgen -d -- "$cur") )
@@ -42,6 +42,7 @@ _fad_check() {
42
42
  '--no-pypi[skip the PyPI codec]'
43
43
  '--no-js[alias: skip JS/npm/yarn]'
44
44
  '--no-retire[skip retire.js vendored-JS scan]'
45
+ '--licenses[run license detection + copyleft policy (off by default)]'
45
46
  '--completion[print shell completion]:shell:(bash zsh)'
46
47
  )
47
48
  _arguments $opts
package/fad-checker.js CHANGED
@@ -25,6 +25,18 @@ const core = require("./lib/core");
25
25
  // (from $bunfs/root) and crashes with ENOENT. Keeps the bun builds fully standalone.
26
26
  const pkg = require("./package.json");
27
27
 
28
+ // -------- compiled-binary retire mode --------
29
+ // The bun-compiled single binary has no node_modules to spawn the retire CLI from,
30
+ // and an air-gapped box has no `retire` on PATH. So lib/retire.js re-execs THIS
31
+ // binary with __FAD_RETIRE__ set; here we hand off to the statically-bundled retire
32
+ // CLI (it self-runs, reading process.argv — bun's argv mirrors node's). The entire
33
+ // normal CLI body is gated behind `else` so fad's own commander setup never runs in
34
+ // retire mode (retire shares commander's singleton `program`). Lets vendored-JS
35
+ // scanning work fully offline from the one binary, no external retire needed.
36
+ if (process.env.__FAD_RETIRE__) {
37
+ require("retire/lib/cli.js");
38
+ } else {
39
+
28
40
  // -------- bash/zsh completion shortcut (must run before required-options parse) --------
29
41
  if (process.argv.includes("--completion")) {
30
42
  const shellIdx = process.argv.indexOf("--completion") + 1;
@@ -210,7 +222,7 @@ program
210
222
  .option("--fail-on <level>", "exit non-zero if a production finding meets <level>: low|medium|high|critical|kev|none", "none")
211
223
  .option("--ignore <file>", "suppress findings listed in <file> (CVE ids / coords / globs, one per line)")
212
224
  .option("--vex <file>", "ingest a CSAF VEX: suppress CVEs marked not_affected/fixed")
213
- .option("--no-licenses", "skip license detection + copyleft policy check")
225
+ .option("--licenses", "run license detection + copyleft policy check (off by default)")
214
226
  .option("--offline", "no network: use cached CVE/OSV/NVD/EPSS/KEV/POM data only")
215
227
  .option("--set-nvd-key <key>", "save NVD API key to ~/.fad-checker/config.json (10× faster NVD enrichment)")
216
228
  .option("--show-config", "print the persisted ~/.fad-checker/config.json")
@@ -248,6 +260,10 @@ program
248
260
  .option("--auth <user:pass>", "Basic auth for --add-repo")
249
261
  .option("--token <token>", "Bearer token for --add-repo")
250
262
  .option("--completion <shell>", "print shell completion script (bash|zsh)");
263
+ // Back-compat: license detection is now OFF by default (enable with --licenses).
264
+ // A legacy `--no-licenses` is therefore a no-op — drop it so old invocations don't
265
+ // trip commander's unknown-option error.
266
+ if (process.argv.includes("--no-licenses")) process.argv = process.argv.filter(a => a !== "--no-licenses");
251
267
  program.parse(process.argv);
252
268
 
253
269
  const options = program.opts();
@@ -396,7 +412,7 @@ async function timedPhase(label, fn) {
396
412
  }
397
413
 
398
414
  (async function main() {
399
- ui.banner();
415
+ ui.banner(pkg.version);
400
416
 
401
417
  // Build the Maven repo list once: persisted repos (from ~/.fad-checker/config.json)
402
418
  // + ad-hoc --repo URLs + Maven Central as final fallback. Used by transitive
@@ -432,7 +448,12 @@ async function timedPhase(label, fn) {
432
448
  ].filter(Boolean))];
433
449
  const defaultExcludes = options.defaultExcludes !== false;
434
450
  const walkOpts = { excludePath, defaultExcludes };
435
- const runMode = options.importAnonymized ? "import descriptor" : (options.offline ? "offline" : "online");
451
+ // PASSI phase 1 (--export-anonymized) is a purely local operation parse the tree,
452
+ // emit public coordinates, exit. Force offline so no source ever touches the network.
453
+ if (options.exportAnonymized) options.offline = true;
454
+ const runMode = options.exportAnonymized ? "offline (PASSI phase 1 · export descriptor)"
455
+ : options.importAnonymized ? "import descriptor"
456
+ : (options.offline ? "offline" : "online");
436
457
  if (options.src) ui.kv("source", chalk.white(options.src));
437
458
  if (mavenRepos.length > 1) ui.kv("repos", chalk.white(mavenRepos.map(r => r.name).join(chalk.dim(" → "))));
438
459
  const otherRegs = Object.keys(regMap).filter(e => e !== "maven" && regMap[e].length);
@@ -544,6 +565,17 @@ async function timedPhase(label, fn) {
544
565
  ui.ok(`${chalk.bold(descriptor.summary.total)} dep(s) (${ecoSummary || "none"}) → ${chalk.white(options.exportAnonymized)}`);
545
566
  ui.info(chalk.dim("public coordinates only — no paths/URLs/host info. Review before transfer."));
546
567
  if (!descriptor.summary.total) ui.warn("no dependencies collected — descriptor is empty");
568
+ // Guide the operator through the remaining two phases (the descriptor file +
569
+ // this run's --src are filled in so the commands are copy-paste ready).
570
+ const descFile = options.exportAnonymized;
571
+ const srcShown = options.src || "./proj";
572
+ ui.section("Next steps");
573
+ ui.info(`${chalk.dim("Phase 2 —")} ${chalk.cyan("ONLINE")} ${chalk.dim("(any box, no --src): warm the caches, then bundle them")}`);
574
+ ui.info(" " + chalk.white(`fad-checker --import-anonymized ${descFile}`));
575
+ ui.info(" " + chalk.white("fad-checker --export-cache fad-cache.tar.gz"));
576
+ ui.info(`${chalk.dim("Phase 3 —")} ${chalk.cyan("OFFLINE")} ${chalk.dim("(back here): import the cache, then run the full report")}`);
577
+ ui.info(" " + chalk.white("fad-checker --import-cache fad-cache.tar.gz"));
578
+ ui.info(" " + chalk.white(`fad-checker -s ${srcShown} --offline`));
547
579
  return;
548
580
  }
549
581
 
@@ -565,6 +597,18 @@ async function timedPhase(label, fn) {
565
597
  }
566
598
  }
567
599
 
600
+ // Mirror every non-Maven lockfile/manifest (npm/yarn/pnpm, composer, pypi, nuget,
601
+ // go, ruby) into the cleaned tree so `snyk test --all-projects` scans the WHOLE
602
+ // polyglot project, not just the cleaned POMs. Maven POMs are the rewrite above.
603
+ let copiedManifests = 0;
604
+ if (!readOnly) {
605
+ try {
606
+ const { copyEcosystemManifests } = require("./lib/manifest-copy");
607
+ const r = await copyEcosystemManifests(options.src, options.target, { excludePath, defaultExcludes });
608
+ copiedManifests = r.copied;
609
+ } catch (err) { console.error(chalk.red(" ✗ manifest copy failed:"), err.message); }
610
+ }
611
+
568
612
  // ---------- Maven POM analysis summary (parents missing / excluded) ----------
569
613
  let privateLibIds = [];
570
614
  if (runMaven && mavenCtx) {
@@ -642,11 +686,15 @@ async function timedPhase(label, fn) {
642
686
  else ui.info(chalk.dim(`${wrotePom} POM(s) cleanable (read-only — pass -t <dir> to write them)`));
643
687
  }
644
688
 
689
+ if (!readOnly && copiedManifests) {
690
+ ui.ok(`${chalk.bold(copiedManifests)} non-Maven lockfile/manifest(s) mirrored → ${chalk.white(options.target)} ${chalk.dim("(so snyk --all-projects scans every ecosystem)")}`);
691
+ }
692
+
645
693
  // ---------- Scan flow (CVE / EOL / Obsolete) ----------
646
694
  // The scan always runs — it feeds the terminal summary, the file outputs and the
647
695
  // CI gate. Which files get written is decided by the --report-* family inside
648
696
  // (HTML + .doc by default; --no-report writes nothing).
649
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings });
697
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store });
650
698
  if (!readOnly) {
651
699
  ui.section("Next step");
652
700
  ui.info(`run Snyk on the cleaned tree:`);
@@ -655,7 +703,7 @@ async function timedPhase(label, fn) {
655
703
  })();
656
704
 
657
705
  async function runReportFlow(resolved, ecoFlags = {}) {
658
- const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [] } = ecoFlags;
706
+ const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null } = ecoFlags;
659
707
  const registriesFor = eco => regMap[eco] || [];
660
708
  const { expandWithTransitives } = require("./lib/cve-match");
661
709
  const { writeReports, computeStats } = require("./lib/cve-report");
@@ -669,13 +717,9 @@ async function runReportFlow(resolved, ecoFlags = {}) {
669
717
  const npmWarnings = collectWarnings || [];
670
718
  let scanWarnings = [];
671
719
  const directCount = resolved.size;
672
-
673
- // Scan-completeness signals: BOMs and unresolved-version deps mean fad-checker
674
- // has gone as far as it can without running Maven/Snyk itself.
675
- if (runMaven) {
676
- const { detectScanCompletenessWarnings } = require("./lib/scan-completeness");
677
- scanWarnings = detectScanCompletenessWarnings(resolved, { ranSnyk: !!options.snyk, ranTransitive: !!options.transitive });
678
- }
720
+ // NOTE: scan-completeness (unresolved-versions) is computed LATER — after the BOM
721
+ // version-resolution step backfills external-BOM-managed versions so it reflects
722
+ // what's *genuinely* still unresolved, not what a Maven Central BOM fetch will fix.
679
723
 
680
724
  // ---- Vulnerability database update (global step progress) ----
681
725
  ui.section("Vulnerability database update");
@@ -694,6 +738,17 @@ async function runReportFlow(resolved, ecoFlags = {}) {
694
738
  const otherRegistryIds = activeIds.filter(id => id !== "maven" && id !== "npm" && id !== "yarn" && getCodec(id)?.checkRegistry);
695
739
  const willCve = !!cveScanner && (!(options.cveOffline || offline) || cveIndexExists);
696
740
  const willTransitive = !!(options.transitive && runMaven);
741
+ // Per-module version mediation overlay: recover transitive versions the global
742
+ // transitive pass masks via cross-module depMgmt bleed. Runs after (and only when)
743
+ // the global pass runs, and needs the parsed store + per-module props.
744
+ const willOverlay = willTransitive && !!mavenPropsByPom && !!mavenStore;
745
+ // External import BOMs (e.g. spring-boot-dependencies): resolve their managed
746
+ // versions to backfill declared deps that pin no version of their own (the usual
747
+ // Spring Boot setup). Cached via poms-cache; offline-aware (uses warmed POMs,
748
+ // never the network — same as transitive resolution).
749
+ const importBoms = (runMaven && mavenPropsByPom)
750
+ ? require("./lib/maven-bom").collectImportBoms(mavenPropsByPom) : [];
751
+ const willBom = importBoms.length > 0;
697
752
  const willOsv = !!options.osv;
698
753
  const willOutdated = !!options.allLibs;
699
754
  const willNvd = !!options.nvd;
@@ -705,9 +760,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
705
760
  const willBinaryId = [...resolved.values()].some(d => d.provenance === "binary");
706
761
  // License detection piggybacks on the registry passes (same fetched metadata),
707
762
  // so it adds no progress step of its own.
708
- const totalSteps = [willTransitive, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
763
+ const totalSteps = [willBom, willTransitive, willOverlay, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
709
764
  const progress = new ui.Progress(totalSteps);
710
765
 
766
+ if (willBom) {
767
+ const st = progress.start("BOM version resolution (Maven Central)");
768
+ try {
769
+ const { resolveAndBackfill } = require("./lib/maven-bom");
770
+ const r = await resolveAndBackfill(mavenPropsByPom, resolved, { repos: mavenRepos, offline, verbose });
771
+ st.done(`${r.filled} dep version(s) from ${r.boms} import BOM(s)`);
772
+ } catch (err) { st.fail(err.message); }
773
+ }
774
+
711
775
  if (willTransitive) {
712
776
  const st = progress.start("Transitive resolution (Maven Central)");
713
777
  await expandWithTransitives(resolved, {
@@ -720,6 +784,32 @@ async function runReportFlow(resolved, ecoFlags = {}) {
720
784
  st.done(`+${resolved.size - directCount} transitive (total ${resolved.size})`);
721
785
  }
722
786
 
787
+ if (willOverlay) {
788
+ const st = progress.start("Per-module version mediation (masked transitives)");
789
+ try {
790
+ const { expandPerModuleOverlay } = require("./lib/version-overlay");
791
+ const ov = await expandPerModuleOverlay(resolved, mavenStore, mavenPropsByPom, {
792
+ verbose,
793
+ offline,
794
+ maxDepth: parseInt(options.transitiveDepth, 10) || 6,
795
+ includeTestDeps: !options.ignoreTest,
796
+ repos: mavenRepos,
797
+ });
798
+ st.done(`+${ov.appended} masked version(s) recovered across ${ov.modules} module(s)`);
799
+ if (verbose && ov.recovered.length) {
800
+ for (const r of ov.recovered) console.log(` ↳ ${r.coord}: +${r.version} (had ${r.had}) via ${r.module}`);
801
+ }
802
+ } catch (err) { st.fail(err.message); }
803
+ }
804
+
805
+ // Scan-completeness signals — computed NOW (after BOM backfill) so only the deps
806
+ // still without a concrete version (external BOM truly unreachable, or offline)
807
+ // are flagged, not the ones we just resolved from spring-boot-dependencies & co.
808
+ if (runMaven) {
809
+ const { detectScanCompletenessWarnings } = require("./lib/scan-completeness");
810
+ scanWarnings = detectScanCompletenessWarnings(resolved, { ranSnyk: !!options.snyk, ranTransitive: !!options.transitive });
811
+ }
812
+
723
813
  // 1. CVE — native scanner contributed by the maven codec (local cvelistV5 index).
724
814
  let cveMatches = [];
725
815
  let cveDataDate = null;
@@ -887,6 +977,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
887
977
  // scanner is owned by the npm codec but runs whenever --retire is on.
888
978
  let retireMatches = [];
889
979
  let vendoredJsInventory = [];
980
+ let retireWarnings = [];
890
981
  if (willRetire) {
891
982
  const st = progress.start("retire.js (vendored JS)");
892
983
  const sc = (getCodec("npm").nativeScanners || []).find(s => s.kind === "vendored");
@@ -898,8 +989,15 @@ async function runReportFlow(resolved, ecoFlags = {}) {
898
989
  retireMatches = r.matches || [];
899
990
  if (options.vendoredJsInventory !== false) vendoredJsInventory = r.meta?.inventory || [];
900
991
  const invN = vendoredJsInventory.length;
901
- st.done(`${retireMatches.length} finding(s)${invN ? ` · ${invN} lib(s) inventoried` : ""}`);
902
- } catch (err) { st.fail(err.message); }
992
+ if (r.meta?.error) {
993
+ // A genuine scan failure — surface it instead of letting an empty
994
+ // vendored-JS chapter look like "nothing found".
995
+ st.fail(r.meta.error);
996
+ retireWarnings.push({ type: "retire-failed", message: `${r.meta.error} — the vendored-JS scan (chapters 1D / 2) could not run, so any vendored \`.js\` (jQuery, Bootstrap, …) is NOT covered. Re-run with \`-v\` for the exact error; check the \`--src\` path exists and is readable.` });
997
+ } else {
998
+ st.done(`${retireMatches.length} finding(s)${invN ? ` · ${invN} lib(s) inventoried` : ""}`);
999
+ }
1000
+ } catch (err) { st.fail(err.message); retireWarnings.push({ type: "retire-failed", message: `retire.js scan failed: ${err.message} — vendored-JS chapters (1D / 2) not covered.` }); }
903
1001
  }
904
1002
  }
905
1003
 
@@ -1038,7 +1136,12 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1038
1136
  ui.warn(`${scanWarnings.length} scan-completeness note(s) — a real Maven/Snyk run may surface more:`);
1039
1137
  for (const w of scanWarnings) {
1040
1138
  ui.info(chalk.dim(`[${w.type}] ${w.message}`));
1041
- for (const it of (w.items || []).slice(0, 4)) console.log(" " + chalk.dim(`· ${it}`));
1139
+ // Items may be plain strings or { id, manifestPaths } objects (the
1140
+ // unresolved-versions warning carries the defining manifest paths).
1141
+ for (const it of (w.items || []).slice(0, 4)) {
1142
+ const id = typeof it === "string" ? it : it.id;
1143
+ console.log(" " + chalk.dim(`· ${id}`));
1144
+ }
1042
1145
  if ((w.items || []).length > 4) console.log(" " + chalk.dim(`· …and ${w.items.length - 4} more`));
1043
1146
  }
1044
1147
  }
@@ -1102,6 +1205,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1102
1205
  }] : []),
1103
1206
  ...npmWarnings,
1104
1207
  ...scanWarnings,
1208
+ ...retireWarnings,
1105
1209
  ...(privateLibIds.length ? [{
1106
1210
  type: "private-libs",
1107
1211
  count: privateLibIds.length,
@@ -1231,3 +1335,5 @@ function mergeBySource(existing, additions) {
1231
1335
  });
1232
1336
  return merged;
1233
1337
  }
1338
+
1339
+ } // end: compiled-binary retire-mode guard (see top of file)
@@ -21,8 +21,8 @@ const retireScanner = {
21
21
  kind: "vendored", // résultats → chapitre vendored-JS (séparé des CVE)
22
22
  async scan(_deps, opts = {}) {
23
23
  const { scanWithRetireFull } = require("../retire");
24
- const { matches, inventory } = await scanWithRetireFull(opts.src, { verbose: opts.verbose, force: !!opts.retireRefresh, offline: !!opts.offline });
25
- return { matches, meta: { inventory } };
24
+ const { matches, inventory, error } = await scanWithRetireFull(opts.src, { verbose: opts.verbose, force: !!opts.retireRefresh, offline: !!opts.offline });
25
+ return { matches, meta: { inventory, error } };
26
26
  },
27
27
  };
28
28
 
package/lib/cve-match.js CHANGED
@@ -180,12 +180,17 @@ function matchOne(dep, entries, confidence) {
180
180
  // the same g:a), not just the representative highest — otherwise a CVE that
181
181
  // only affects a lower-versioned profile variant would be missed.
182
182
  const versions = (dep.versions && dep.versions.length) ? dep.versions : [dep.version];
183
+ // Only CONCRETE, resolved versions can be matched. A dep whose version is null
184
+ // or an unresolved `${property}` (typically pinned by an external BOM like
185
+ // spring-boot-dependencies) carries NO version we can compare — matching it
186
+ // against every "affected" range would flag it as vulnerable to ALL the
187
+ // coordinate's CVEs (a flood of false CRITICALs with version "?"). Skip it;
188
+ // it's already reported in chapter 0 as an "unresolved-versions" warning.
189
+ const concrete = versions.filter(v => v && !/\$\{/.test(String(v)));
190
+ if (!concrete.length) return matches;
183
191
  for (const e of entries) {
184
- for (const ver of versions) {
185
- const affected = (e.ranges || []).some(r => {
186
- if (!ver) return r.status === "affected"; // unknown version → assume affected
187
- return isVersionAffected(ver, r);
188
- });
192
+ for (const ver of concrete) {
193
+ const affected = (e.ranges || []).some(r => isVersionAffected(ver, r));
189
194
  if (affected) {
190
195
  const vdep = ver === dep.version ? dep : { ...dep, version: ver };
191
196
  matches.push({ dep: vdep, cve: { ...e }, confidence });