fad-checker 2.2.0 → 2.2.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.
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
 
@@ -887,6 +919,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
887
919
  // scanner is owned by the npm codec but runs whenever --retire is on.
888
920
  let retireMatches = [];
889
921
  let vendoredJsInventory = [];
922
+ let retireWarnings = [];
890
923
  if (willRetire) {
891
924
  const st = progress.start("retire.js (vendored JS)");
892
925
  const sc = (getCodec("npm").nativeScanners || []).find(s => s.kind === "vendored");
@@ -898,8 +931,15 @@ async function runReportFlow(resolved, ecoFlags = {}) {
898
931
  retireMatches = r.matches || [];
899
932
  if (options.vendoredJsInventory !== false) vendoredJsInventory = r.meta?.inventory || [];
900
933
  const invN = vendoredJsInventory.length;
901
- st.done(`${retireMatches.length} finding(s)${invN ? ` · ${invN} lib(s) inventoried` : ""}`);
902
- } catch (err) { st.fail(err.message); }
934
+ if (r.meta?.error) {
935
+ // A genuine scan failure — surface it instead of letting an empty
936
+ // vendored-JS chapter look like "nothing found".
937
+ st.fail(r.meta.error);
938
+ 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.` });
939
+ } else {
940
+ st.done(`${retireMatches.length} finding(s)${invN ? ` · ${invN} lib(s) inventoried` : ""}`);
941
+ }
942
+ } 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
943
  }
904
944
  }
905
945
 
@@ -1102,6 +1142,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1102
1142
  }] : []),
1103
1143
  ...npmWarnings,
1104
1144
  ...scanWarnings,
1145
+ ...retireWarnings,
1105
1146
  ...(privateLibIds.length ? [{
1106
1147
  type: "private-libs",
1107
1148
  count: privateLibIds.length,
@@ -1231,3 +1272,5 @@ function mergeBySource(existing, additions) {
1231
1272
  });
1232
1273
  return merged;
1233
1274
  }
1275
+
1276
+ } // 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-report.js CHANGED
@@ -13,6 +13,7 @@ const path = require("path");
13
13
  const { getCodec, allCodecs, ORDER } = require("./codecs");
14
14
  const { computePriority, sortByPriority } = require("./priority");
15
15
  const { buildInventory } = require("./unmanaged");
16
+ const { buildEmbeddedInventory } = require("./embedded");
16
17
 
17
18
  // CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
18
19
  // back to the raw id (with a "—" placeholder name where the row asks for one).
@@ -935,17 +936,42 @@ function renderRecommendationsForEco(slice, meta) {
935
936
  return blocks.join("");
936
937
  }
937
938
 
939
+ // Where a dependency comes from in the tree. A transitive dep (no defining
940
+ // manifest) carries `via` — the chain of g:a from a root direct dep down to it —
941
+ // recorded by the transitive resolver. Show that chain so an EOL/CVE finding on a
942
+ // "dep of a dep" is traceable to the direct dependency that pulls it in. A direct
943
+ // dep falls back to its defining manifest(s).
944
+ function renderDepOrigin(dep, srcRoot) {
945
+ if (dep.via && dep.via.length) {
946
+ const chain = dep.via.map(c => `<code>${esc(c)}</code>`).join(" → ");
947
+ const tail = `<code>${esc(`${dep.groupId}:${dep.artifactId}`)}</code>`;
948
+ const alt = (dep.viaPaths && dep.viaPaths.length > 1) ? ` <span class="defined-more">+${dep.viaPaths.length - 1} more path${dep.viaPaths.length > 2 ? "s" : ""}</span>` : "";
949
+ return `<div class="defined-in"><span class="defined-label">pulled in via:</span> ${chain} → ${tail}${alt}</div>`;
950
+ }
951
+ return renderDefinedIn(dep, srcRoot);
952
+ }
953
+
954
+ // The source of an EOL verdict: the endoflife.date product queried, and which
955
+ // mapping rule (data/eol-mapping.json) matched the dep to that product.
956
+ function renderEolSource(e) {
957
+ const slug = e.productSlug || "";
958
+ const link = slug ? `endoflife.date/${esc(slug)}` : "endoflife.date";
959
+ const rule = e.via ? `<br><span style="color:#6b7280">matched via ${esc(e.via)} = <code>${esc(e.viaKey || "")}</code></span>` : "";
960
+ return `<span>${link}</span>${rule}`;
961
+ }
962
+
938
963
  function renderEolTable(eolResults) {
939
964
  if (!eolResults?.length) return `<div class="empty">No end-of-life frameworks detected.</div>`;
940
965
  const rows = eolResults.map(e => `<tr>
941
966
  <td class="dep">${esc(e.product)}</td>
942
- <td class="dep">${esc(depDisplayName(e.dep))}<br><span style="color:#6b7280">${esc(e.dep.version || "?")}</span>${renderDefinedIn(e.dep, RENDER_CTX.srcRoot)}</td>
967
+ <td class="dep">${esc(depDisplayName(e.dep))}<br><span style="color:#6b7280">${esc(e.dep.version || "?")}</span>${renderDepOrigin(e.dep, RENDER_CTX.srcRoot)}</td>
943
968
  <td>${esc(e.eol || "")}</td>
944
969
  <td>${esc(e.latest || "")}</td>
970
+ <td class="dep">${renderEolSource(e)}</td>
945
971
  <td class="desc">${esc(e.notes || "")}</td>
946
972
  </tr>`).join("\n");
947
973
  return `<table>
948
- <thead><tr><th>Product</th><th>Dependency</th><th>EOL date</th><th>Latest</th><th>Notes</th></tr></thead>
974
+ <thead><tr><th>Product</th><th>Dependency</th><th>EOL date</th><th>Latest</th><th>Source</th><th>Notes</th></tr></thead>
949
975
  <tbody>${rows}</tbody>
950
976
  </table>`;
951
977
  }
@@ -1338,7 +1364,7 @@ const LICENSE_CAT_ORDER = ["network-copyleft", "strong-copyleft", "proprietary",
1338
1364
 
1339
1365
  function renderLicenseChapter(licenseResults) {
1340
1366
  if (!licenseResults || !licenseResults.assessed?.length) {
1341
- return `<div class="empty">No license data resolved (offline, or registries returned none).</div>`;
1367
+ return `<div class="empty">No license data the license scan is off by default (enable with <code>--licenses</code>); also empty when offline or registries return none.</div>`;
1342
1368
  }
1343
1369
  const { byCategory } = licenseResults;
1344
1370
  const blocks = LICENSE_CAT_ORDER.filter(cat => byCategory[cat]?.length).map(cat => {
@@ -1397,7 +1423,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1397
1423
  const licenseContent = renderLicenseChapter(licenseResults);
1398
1424
  const licenseTotal = licenseResults?.assessed?.length || 0;
1399
1425
  const licenseFlagged = licenseResults?.flagged?.length || 0;
1400
- const embeddedContent = renderEmbeddedChapter(embMatchesActive, projectInfo?.src);
1426
+ const embeddedInventory = buildEmbeddedInventory(resolvedDeps || new Map(), embMatchesActive);
1427
+ const embeddedContent = renderEmbeddedChapter(embeddedInventory, embMatchesActive, projectInfo?.src);
1401
1428
  const unmanagedInventory = buildInventory(resolvedDeps || new Map());
1402
1429
  const unmanagedContent = renderUnmanagedInventory(unmanagedInventory, projectInfo?.src);
1403
1430
  const vendoredJsInv = vendoredJsInventory || [];
@@ -1433,7 +1460,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1433
1460
  outdatedTotal: outdatedResults?.length || 0,
1434
1461
  licenseTotal,
1435
1462
  licenseFlagged,
1436
- embeddedTotal: embStats.total,
1463
+ embeddedTotal: embeddedInventory.length,
1437
1464
  unmanagedTotal: unmanagedInventory.length,
1438
1465
  vendoredJsTotal: vendoredJsInv.length,
1439
1466
  fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
@@ -1455,7 +1482,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1455
1482
 
1456
1483
  ${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
1457
1484
  ${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
1458
- ${embStats.total ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embStats.total})`, embeddedContent, { open: embStats.total <= 50, id: "ch1e" }) : ""}
1485
+ ${embeddedInventory.length ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embeddedInventory.length})`, embeddedContent, { open: embeddedInventory.length <= 50, id: "ch1e" }) : ""}
1459
1486
  ${unmanagedInventory.length ? majorSection(`1C. Unmanaged / vendored binaries (${unmanagedInventory.length})`, unmanagedContent, { open: unmanagedInventory.length <= 50, id: "ch1c" }) : ""}
1460
1487
  ${vendoredJsInv.length ? majorSection(`1D. Unmanaged / vendored JavaScript (${vendoredJsInv.length})`, vendoredJsContent, { open: vendoredJsInv.length <= 50, id: "ch1d" }) : ""}
1461
1488
  ${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
@@ -1520,22 +1547,39 @@ function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vend
1520
1547
  return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
1521
1548
  }
1522
1549
 
1523
- // Embedded-binary CVE chapter: group findings by their top-level archive (the
1524
- // part of the manifestPath before the first "!/"), so a fat-jar lists all the
1525
- // vulnerable libraries shaded inside it.
1526
- function renderEmbeddedChapter(matches, srcRoot) {
1527
- if (!matches.length) return `<div class="empty">No CVEs found in embedded binaries.</div>`;
1528
- const intro = `<div class="fp-intro">Maven coordinates discovered inside committed <code>.jar</code>/<code>.war</code>/<code>.ear</code> binaries (vendored libs, fat-jars) — not declared in any <code>pom.xml</code>. Patch by rebuilding/replacing the containing archive.</div>`;
1550
+ // Embedded-binary inventory chapter (1B): every embedded coordinate grouped by its
1551
+ // top-level archive (the part of the manifestPath before the first "!/"), with its
1552
+ // CVE status. Vulnerable coords additionally get the full CVE detail table.
1553
+ function renderEmbeddedChapter(inventory, embeddedMatches, srcRoot) {
1554
+ if (!inventory.length) return `<div class="empty">No embedded JAR/WAR/EAR coordinates found.</div>`;
1555
+ const vulnN = inventory.filter(e => e.vulnCount > 0).length;
1556
+ const intro = `<div class="fp-intro">Maven coordinates discovered inside committed <code>.jar</code>/<code>.war</code>/<code>.ear</code> binaries (vendored libs, fat-jars, shaded uber-jars) — <strong>not declared in any <code>pom.xml</code></strong>. Unmanaged third-party code: unknown provenance and patch story even when not vulnerable. <strong>${vulnN}</strong> of ${inventory.length} carry known CVEs (patch by rebuilding/replacing the containing archive).</div>`;
1557
+ // CVE matches keyed by archive, for the per-archive detail tables.
1558
+ const matchesByArchive = new Map();
1559
+ for (const m of (embeddedMatches || [])) {
1560
+ const top = String((m.dep?.manifestPaths || [])[0] || "(unknown archive)").split("!/")[0];
1561
+ if (!matchesByArchive.has(top)) matchesByArchive.set(top, []);
1562
+ matchesByArchive.get(top).push(m);
1563
+ }
1529
1564
  const byArchive = new Map();
1530
- for (const m of matches) {
1531
- const mp = (m.dep?.manifestPaths || [])[0] || "(unknown archive)";
1532
- const top = String(mp).split("!/")[0];
1533
- if (!byArchive.has(top)) byArchive.set(top, []);
1534
- byArchive.get(top).push(m);
1565
+ for (const e of inventory) {
1566
+ if (!byArchive.has(e.archive)) byArchive.set(e.archive, []);
1567
+ byArchive.get(e.archive).push(e);
1535
1568
  }
1536
- const blocks = [...byArchive.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([archive, list]) =>
1537
- minorSection(`📦 <code class="path">${esc(archive)}</code> (${list.length})`, renderCveTable(list), { open: list.length <= 30 })
1538
- ).join("\n");
1569
+ const blocks = [...byArchive.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([archive, list]) => {
1570
+ const rows = list.map(e => {
1571
+ const coord = `${e.groupId}:${e.artifactId}:${e.version}`;
1572
+ const status = e.vulnCount > 0
1573
+ ? pill(`${e.vulnCount} CVE${e.vulnCount > 1 ? "s" : ""}${e.maxSeverity ? " · " + e.maxSeverity : ""}`, "#b91c1c")
1574
+ : pill("no known CVE", "#15803d");
1575
+ return `<tr><td class="dep"><code>${esc(coord)}</code></td><td>${status}</td></tr>`;
1576
+ }).join("\n");
1577
+ const invTable = `<table><thead><tr><th>Coordinate (groupId:artifactId:version)</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
1578
+ const cveDetail = matchesByArchive.has(archive)
1579
+ ? `<div class="defined-in" style="margin-top:8px"><span class="defined-label">CVE detail:</span></div>` + renderCveTable(matchesByArchive.get(archive))
1580
+ : "";
1581
+ return minorSection(`📦 <code class="path">${esc(archive)}</code> (${list.length})`, invTable + cveDetail, { open: list.length <= 30 });
1582
+ }).join("\n");
1539
1583
  return intro + blocks;
1540
1584
  }
1541
1585
 
@@ -1984,6 +2028,7 @@ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retire
1984
2028
 
1985
2029
  module.exports = {
1986
2030
  computeStats,
2031
+ buildEmbeddedInventory,
1987
2032
  generateHtmlReport,
1988
2033
  generateWordReport,
1989
2034
  writeReports,
@@ -0,0 +1,57 @@
1
+ /**
2
+ * lib/embedded.js — inventory of Maven coordinates discovered inside committed
3
+ * .jar/.war/.ear archives (provenance:"embedded"), whether or not they carry a CVE.
4
+ *
5
+ * This is a governance / cyber-hygiene signal: the JAR twin of the native-binary
6
+ * inventory (lib/unmanaged.js → chapter 1C) and the vendored-JS inventory
7
+ * (lib/retire.js → chapter 1D). Code that ships inside a committed binary which no
8
+ * pom.xml declares has unknown provenance and patch story even when not vulnerable.
9
+ *
10
+ * Pure: shared by the HTML report (chapter 1B) and the JSON export so both list the
11
+ * SAME set. CVE counts/severity are cross-referenced from the embedded CVE matches
12
+ * by coordKey.
13
+ */
14
+
15
+ const EMB_SEV_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOWN: 0 };
16
+
17
+ /**
18
+ * @param resolvedDeps Map of all resolved deps (embedded ones carry provenance:"embedded").
19
+ * @param embeddedMatches CVE matches whose dep is embedded (subset of the scan's matches).
20
+ * @returns sorted array of { archive, groupId, artifactId, version, coordKey, manifestPath, vulnCount, maxSeverity }
21
+ */
22
+ function buildEmbeddedInventory(resolvedDeps, embeddedMatches) {
23
+ const cveByCoord = new Map(); // coordKey → { ids:Set, maxSeverity }
24
+ for (const m of (embeddedMatches || [])) {
25
+ const key = m.dep?.coordKey;
26
+ if (!key) continue;
27
+ let e = cveByCoord.get(key);
28
+ if (!e) { e = { ids: new Set(), maxSeverity: null }; cveByCoord.set(key, e); }
29
+ e.ids.add(m.cve?.id);
30
+ const sev = m.cve?.severity || "UNKNOWN";
31
+ if (!e.maxSeverity || (EMB_SEV_RANK[sev] || 0) > (EMB_SEV_RANK[e.maxSeverity] || 0)) e.maxSeverity = sev;
32
+ }
33
+ const out = [];
34
+ for (const dep of (resolvedDeps?.values?.() || [])) {
35
+ if (dep.provenance !== "embedded") continue;
36
+ const manifestPath = (dep.manifestPaths || [])[0] || "(unknown archive)";
37
+ const archive = String(manifestPath).split("!/")[0];
38
+ const cve = cveByCoord.get(dep.coordKey);
39
+ out.push({
40
+ archive,
41
+ groupId: dep.groupId || dep.namespace || "",
42
+ artifactId: dep.artifactId || dep.name || "",
43
+ version: dep.version || "",
44
+ coordKey: dep.coordKey,
45
+ manifestPath,
46
+ vulnCount: cve ? cve.ids.size : 0,
47
+ maxSeverity: cve ? cve.maxSeverity : null,
48
+ });
49
+ }
50
+ out.sort((a, b) =>
51
+ (EMB_SEV_RANK[b.maxSeverity] || 0) - (EMB_SEV_RANK[a.maxSeverity] || 0)
52
+ || String(a.archive).localeCompare(String(b.archive))
53
+ || `${a.groupId}:${a.artifactId}:${a.version}`.localeCompare(`${b.groupId}:${b.artifactId}:${b.version}`));
54
+ return out;
55
+ }
56
+
57
+ module.exports = { buildEmbeddedInventory, EMB_SEV_RANK };
@@ -72,6 +72,10 @@ function buildFindings(payload = {}) {
72
72
  const { buildInventory } = require("./unmanaged");
73
73
  const unmanaged = resolvedDeps ? buildInventory(resolvedDeps) : [];
74
74
 
75
+ const { buildEmbeddedInventory } = require("./embedded");
76
+ const embeddedMatches = cveMatches.filter(m => m.dep?.provenance === "embedded");
77
+ const embedded = resolvedDeps ? buildEmbeddedInventory(resolvedDeps, embeddedMatches) : [];
78
+
75
79
  const sevCounts = Object.fromEntries(SEV.map(s => [s, 0]));
76
80
  let kev = 0;
77
81
  for (const m of cveMatches) {
@@ -94,16 +98,18 @@ function buildFindings(payload = {}) {
94
98
  licensesFlagged: licenseResults?.flagged?.length || 0,
95
99
  vendored: retireMatches.length,
96
100
  unmanaged: unmanaged.length,
101
+ embedded: embedded.length,
97
102
  vendoredJs: vendoredJsInventory.length,
98
103
  suppressed: cveMatches.filter(m => m.suppressed).length,
99
104
  },
100
105
  cve: cveMatches.map(cveFinding),
101
106
  vendored: retireMatches.map(cveFinding),
102
- eol: eolResults.map(e => ({ product: e.product, eol: e.eol, dep: depBrief(e.dep) })),
107
+ eol: eolResults.map(e => ({ product: e.product, productSlug: e.productSlug || null, via: e.via || null, viaKey: e.viaKey || null, eol: e.eol, dep: depBrief(e.dep) })),
103
108
  obsolete: obsoleteResults.map(o => ({ reason: o.reason || null, replacement: o.replacement || null, source: o.source || null, dep: depBrief(o.dep) })),
104
109
  outdated: outdatedResults.map(o => ({ latest: o.latest, releaseDate: o.releaseDate || null, dep: depBrief(o.dep) })),
105
110
  licenses: (licenseResults?.assessed || []).map(e => ({ category: e.category, licenses: e.ids.concat(e.raw), source: e.source || null, dep: depBrief(e.dep) })),
106
111
  unmanaged,
112
+ embedded,
107
113
  vendoredJs: vendoredJsInventory,
108
114
  };
109
115
  }
package/lib/outdated.js CHANGED
@@ -42,6 +42,13 @@ function isEolCacheFresh(maxAge = EOL_CACHE_MAX_AGE_MS) {
42
42
 
43
43
  // -------- EOL via endoflife.date --------
44
44
 
45
+ // Tag the matched mapping entry with HOW it matched (which rule + which key), so
46
+ // the report can show where an EOL verdict comes from. Returns a COPY — the shared
47
+ // EOL_MAPPING objects must never be mutated (they're reused across deps).
48
+ function withOrigin(entry, via, viaKey) {
49
+ return entry ? { ...entry, via, viaKey } : null;
50
+ }
51
+
45
52
  function findEolProduct(dep) {
46
53
  // npm packages — and WebJars, which are client-side JS shipped as Maven
47
54
  // artifacts — resolve by JS library name, not Maven coordinate. npm deps
@@ -49,36 +56,39 @@ function findEolProduct(dep) {
49
56
  // first (org.webjars:angularjs → "angularjs", org.webjars.npm:angular__core
50
57
  // → "@angular/core"). The npm package literally named "angular" is AngularJS
51
58
  // 1.x; modern Angular is the @angular/* scope — hence the name vs. scope maps.
59
+ const isWebjar = dep.ecosystem !== "npm" && webjarToNpm(dep) != null;
52
60
  const npmName = dep.ecosystem === "npm" ? (dep.artifactId || "") : webjarToNpm(dep)?.name;
53
61
  if (npmName != null) {
54
62
  const byName = EOL_MAPPING.by_npm_name?.[npmName];
55
- if (byName) return byName;
63
+ if (byName) return withOrigin(byName, isWebjar ? "webjar" : "npm-name", npmName);
56
64
  const scopes = Object.keys(EOL_MAPPING.by_npm_scope || {})
57
65
  .sort((a, b) => b.length - a.length);
58
66
  for (const s of scopes) {
59
- if (npmName.startsWith(s)) return EOL_MAPPING.by_npm_scope[s];
67
+ if (npmName.startsWith(s)) return withOrigin(EOL_MAPPING.by_npm_scope[s], isWebjar ? "webjar" : "npm-scope", s);
60
68
  }
61
69
  return null;
62
70
  }
63
71
  if (dep.ecosystem === "composer") {
64
72
  const full = `${dep.namespace || dep.groupId || ""}/${dep.name || dep.artifactId}`.toLowerCase();
65
- return EOL_MAPPING.by_composer_name?.[full] || null;
73
+ return withOrigin(EOL_MAPPING.by_composer_name?.[full], "composer-name", full);
66
74
  }
67
75
  if (dep.ecosystem === "pypi") {
68
- return EOL_MAPPING.by_pypi_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
76
+ const k = (dep.name || dep.artifactId || "").toLowerCase();
77
+ return withOrigin(EOL_MAPPING.by_pypi_name?.[k], "pypi-name", k);
69
78
  }
70
79
  if (dep.ecosystem === "nuget") {
71
- return EOL_MAPPING.by_nuget_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
80
+ const k = (dep.name || dep.artifactId || "").toLowerCase();
81
+ return withOrigin(EOL_MAPPING.by_nuget_name?.[k], "nuget-name", k);
72
82
  }
73
83
  const key = `${dep.groupId}:${dep.artifactId}`;
74
84
  const direct = EOL_MAPPING.by_group_artifact?.[key];
75
- if (direct) return direct;
85
+ if (direct) return withOrigin(direct, "group-artifact", key);
76
86
  // Match longest groupId prefix
77
87
  const prefixes = Object.keys(EOL_MAPPING.by_group_prefix || {})
78
88
  .sort((a, b) => b.length - a.length);
79
89
  for (const p of prefixes) {
80
90
  if (dep.groupId === p || dep.groupId.startsWith(p + ".")) {
81
- return EOL_MAPPING.by_group_prefix[p];
91
+ return withOrigin(EOL_MAPPING.by_group_prefix[p], "group-prefix", p);
82
92
  }
83
93
  }
84
94
  return null;
@@ -154,6 +164,8 @@ async function checkEolDeps(resolvedDeps, opts = {}) {
154
164
  dep,
155
165
  product: product.label || product.product,
156
166
  productSlug: product.product,
167
+ via: product.via || null,
168
+ viaKey: product.viaKey || null,
157
169
  cycle: cycle.cycle,
158
170
  eol: cycle.eol === true ? "true" : String(cycle.eol),
159
171
  latest: cycle.latest || null,
package/lib/retire.js CHANGED
@@ -36,15 +36,38 @@ const RETIRE_SIG_DIR = path.join(os.homedir(), ".fad-checker", "retire-signature
36
36
  const RETIRE_SIG_FILE = path.join(RETIRE_SIG_DIR, "jsrepository-v5.json");
37
37
  const RETIRE_REPO_URL = "https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository-v5.json";
38
38
 
39
+ // retire always emits ABSOLUTE file paths (it resolves --jspath). Make them
40
+ // relative to the scan root robustly — resolving BOTH sides so it works whether
41
+ // the caller passed -s as a relative ("./proj") or absolute path. The old
42
+ // `file.startsWith(srcDir)` guard silently left paths absolute for a relative -s.
43
+ function relToSrc(srcDir, file) {
44
+ if (!file) return file;
45
+ if (!srcDir) return file;
46
+ try {
47
+ const rel = path.relative(path.resolve(srcDir), path.resolve(file));
48
+ return rel && !rel.startsWith("..") ? rel : file;
49
+ } catch { return file; }
50
+ }
51
+
39
52
  function cacheKey(srcDir) {
40
53
  return crypto.createHash("md5").update(path.resolve(srcDir)).digest("hex") + ".json";
41
54
  }
42
55
 
56
+ // Cache schema version. Bumped to 2 when retire started running with `--verbose`
57
+ // (so the cached body carries the FULL vendored-JS inventory, not just vulnerable
58
+ // hits). A cached entry without `_schema >= 2` was written by a pre-verbose build
59
+ // (e.g. 1.0.6) and its body holds vuln-only data — trusting it would silently empty
60
+ // the inventory chapter (1D) on an offline re-run. We treat such an entry as a
61
+ // cache MISS so the normal path re-scans (online, or offline with local signatures)
62
+ // and the offline report reproduces the online one.
63
+ const RETIRE_CACHE_SCHEMA = 2;
64
+
43
65
  function readCache(srcDir) {
44
66
  const p = path.join(RETIRE_CACHE_DIR, cacheKey(srcDir));
45
67
  if (!fs.existsSync(p)) return null;
46
68
  try {
47
69
  const data = JSON.parse(fs.readFileSync(p, "utf8"));
70
+ if (!(data._schema >= RETIRE_CACHE_SCHEMA)) return null; // legacy / pre-verbose → re-scan
48
71
  if (Date.now() - data._fetchedAt < RETIRE_CACHE_TTL_MS) return data.body;
49
72
  } catch { /* ignore */ }
50
73
  return null;
@@ -53,7 +76,7 @@ function readCache(srcDir) {
53
76
  function writeCache(srcDir, body) {
54
77
  fs.mkdirSync(RETIRE_CACHE_DIR, { recursive: true });
55
78
  fs.writeFileSync(path.join(RETIRE_CACHE_DIR, cacheKey(srcDir)),
56
- JSON.stringify({ _fetchedAt: Date.now(), body }));
79
+ JSON.stringify({ _schema: RETIRE_CACHE_SCHEMA, _fetchedAt: Date.now(), body }));
57
80
  }
58
81
 
59
82
  function findRetireBin() {
@@ -62,6 +85,28 @@ function findRetireBin() {
62
85
  return "retire"; // fall back to PATH
63
86
  }
64
87
 
88
+ // Decide HOW to launch retire. The compiled (bun) binary has no node_modules to
89
+ // spawn the retire CLI from and the air-gapped box has no `retire` on PATH — so it
90
+ // re-execs ITSELF with __FAD_RETIRE__ set, and the entry point (fad-checker.js)
91
+ // hands off to the statically-bundled retire CLI. Pure for testability.
92
+ // - localBin present (node dev / node_modules) → run it directly.
93
+ // - else running under bun (compiled binary) → self-invoke this executable.
94
+ // - else → `retire` on PATH (last resort).
95
+ function chooseRetireLauncher({ localBin, isBun, execPath }) {
96
+ if (localBin) return { cmd: localBin, env: null };
97
+ if (isBun) return { cmd: execPath, env: { __FAD_RETIRE__: "1" } };
98
+ return { cmd: "retire", env: null };
99
+ }
100
+
101
+ function findRetireLauncher() {
102
+ const local = path.join(__dirname, "..", "node_modules", ".bin", "retire");
103
+ return chooseRetireLauncher({
104
+ localBin: fs.existsSync(local) ? local : null,
105
+ isBun: !!(process.versions && process.versions.bun),
106
+ execPath: process.execPath,
107
+ });
108
+ }
109
+
65
110
  /**
66
111
  * Fetch retire's signature DB to a stable file inside ~/.fad-checker/ so it can
67
112
  * be bundled by --export-cache and reused offline via --jsrepo. Network call —
@@ -117,6 +162,19 @@ function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
117
162
 
118
163
  const SEV_RANK = { critical: 5, high: 4, medium: 3, low: 2, none: 1 };
119
164
 
165
+ // Pick the human-meaningful line out of retire's stderr for a failure message.
166
+ // retire dumps a multi-line stack trace; the useful part is the first non-empty,
167
+ // non-stack-frame line (preferring an ENOENT / "no such file" / permission line).
168
+ function retireFailureReason(stderr, fallback) {
169
+ const lines = String(stderr || "")
170
+ .split("\n")
171
+ .map(l => l.trim())
172
+ .filter(l => l && !/^at\s/.test(l));
173
+ if (!lines.length) return fallback;
174
+ const hot = lines.find(l => /ENOENT|no such file|permission denied|EACCES/i.test(l));
175
+ return hot || lines[0];
176
+ }
177
+
120
178
  /**
121
179
  * Extract the full inventory of identified vendored JS libraries (vulnerable or
122
180
  * not) from retire's --verbose output. Each entry: the standalone library, where
@@ -131,7 +189,7 @@ function extractVendoredInventory(raw, srcDir) {
131
189
  const files = Array.isArray(raw) ? raw : (raw.data || []);
132
190
  for (const f of files) {
133
191
  const file = f.file;
134
- const relFile = srcDir && file?.startsWith(srcDir) ? path.relative(srcDir, file) : file;
192
+ const relFile = relToSrc(srcDir, file);
135
193
  for (const res of f.results || []) {
136
194
  if (!res.component) continue;
137
195
  const vulns = res.vulnerabilities || [];
@@ -169,6 +227,10 @@ function extractVendoredInventory(raw, srcDir) {
169
227
  */
170
228
  async function runRetire(srcDir, opts = {}) {
171
229
  const { verbose, force, offline } = opts;
230
+ // Optional diagnostics collector: callers (scanWithRetireFull) read diag.error to
231
+ // surface a genuine SCAN FAILURE (retire crashed / produced no parseable output)
232
+ // as a report warning — instead of letting it masquerade as "no vendored JS found".
233
+ const diag = opts.diag || {};
172
234
  // No source tree (e.g. --import-anonymized) → nothing to scan for vendored JS.
173
235
  if (!srcDir) { if (verbose) console.warn("retire: no source dir — skipped"); return null; }
174
236
  // Findings-cache fast path (path-keyed). Works online and offline.
@@ -189,7 +251,7 @@ async function runRetire(srcDir, opts = {}) {
189
251
  return null;
190
252
  }
191
253
 
192
- const bin = findRetireBin();
254
+ const launcher = findRetireLauncher();
193
255
  const ignoredDirs = [
194
256
  "node_modules", "bower_components", "jspm_packages",
195
257
  ".git", ".idea", ".vscode", ".gradle", ".mvn",
@@ -202,15 +264,27 @@ async function runRetire(srcDir, opts = {}) {
202
264
  const args = buildRetireArgs({ srcDir, outPath: tmpOut, ignoredDirs, jsRepo: haveSig ? RETIRE_SIG_FILE : null });
203
265
  if (verbose) console.log(`retire: scanning ${srcDir}…${haveSig ? " (local signatures)" : ""}`);
204
266
 
267
+ let execErr = null;
205
268
  try {
206
269
  // retire.js exits with code 13 when it finds vulnerabilities — that's
207
270
  // expected. Catch and ignore the non-zero exit; the JSON file is still
208
271
  // produced.
209
- await execFileP(bin, args, { maxBuffer: 1024 * 1024 * 64 });
272
+ await execFileP(launcher.cmd, args, {
273
+ maxBuffer: 1024 * 1024 * 64,
274
+ env: launcher.env ? { ...process.env, ...launcher.env } : process.env,
275
+ });
210
276
  } catch (err) {
211
- // exit code 13 (or anything where the output file exists) is OK
212
- if (!fs.existsSync(tmpOut)) {
213
- if (verbose) console.warn(`retire: failed to run ${err.message}`);
277
+ execErr = err;
278
+ // exit code 13 (or anything where a non-empty output file exists) is OK.
279
+ // A missing OR empty (0-byte) output file means retire crashed mid-walk
280
+ // (e.g. ENOENT on the source path, an unreadable file) — a real failure.
281
+ let size = -1;
282
+ try { size = fs.statSync(tmpOut).size; } catch { /* missing */ }
283
+ if (size <= 0) {
284
+ const reason = retireFailureReason(err.stderr, err.message);
285
+ diag.error = `retire.js scan failed: ${reason}`;
286
+ if (verbose) console.warn(`retire: failed to run — ${reason}`);
287
+ try { fs.unlinkSync(tmpOut); } catch { /* best effort */ }
214
288
  return null;
215
289
  }
216
290
  }
@@ -220,6 +294,8 @@ async function runRetire(srcDir, opts = {}) {
220
294
  const body = fs.readFileSync(tmpOut, "utf8");
221
295
  parsed = JSON.parse(body);
222
296
  } catch (err) {
297
+ const reason = retireFailureReason(execErr && execErr.stderr, err.message);
298
+ diag.error = `retire.js scan failed: ${reason}`;
223
299
  if (verbose) console.warn(`retire: could not parse output — ${err.message}`);
224
300
  return null;
225
301
  } finally {
@@ -262,7 +338,7 @@ function normaliseRetireResults(raw, srcDir) {
262
338
  const files = Array.isArray(raw) ? raw : (raw.data || []);
263
339
  for (const f of files) {
264
340
  const file = f.file;
265
- const relFile = srcDir && file?.startsWith(srcDir) ? path.relative(srcDir, file) : file;
341
+ const relFile = relToSrc(srcDir, file);
266
342
  for (const res of f.results || []) {
267
343
  const component = res.component;
268
344
  const version = res.version;
@@ -319,11 +395,13 @@ async function scanWithRetire(srcDir, opts = {}) {
319
395
  * JavaScript"). One retire run feeds both.
320
396
  */
321
397
  async function scanWithRetireFull(srcDir, opts = {}) {
322
- const raw = await runRetire(srcDir, opts);
323
- if (!raw) return { matches: [], inventory: [] };
398
+ const diag = {};
399
+ const raw = await runRetire(srcDir, { ...opts, diag });
400
+ if (!raw) return { matches: [], inventory: [], error: diag.error || null };
324
401
  return {
325
402
  matches: normaliseRetireResults(raw, srcDir),
326
403
  inventory: extractVendoredInventory(raw, srcDir),
404
+ error: null,
327
405
  };
328
406
  }
329
407
 
@@ -334,9 +412,16 @@ module.exports = {
334
412
  runRetire,
335
413
  normaliseRetireResults,
336
414
  findRetireBin,
415
+ findRetireLauncher,
416
+ chooseRetireLauncher,
337
417
  warmRetireSignatures,
338
418
  ensureSignatures,
339
419
  buildRetireArgs,
420
+ retireFailureReason,
421
+ readCache,
422
+ writeCache,
423
+ cacheKey,
424
+ RETIRE_CACHE_SCHEMA,
340
425
  RETIRE_CACHE_DIR,
341
426
  RETIRE_SIG_DIR,
342
427
  RETIRE_SIG_FILE,
@@ -53,15 +53,24 @@ function detectScanCompletenessWarnings(resolvedDeps, opts = {}) {
53
53
  }
54
54
 
55
55
  if (unresolved.length) {
56
- // Dedupe by g:a (same coord may appear in several modules)
57
- const seenKeys = new Set();
58
- const items = [];
56
+ // Dedupe by g:a (same coord may appear in several modules), MERGING the
57
+ // manifest paths so the report can show WHERE each unresolved dep is declared.
58
+ // Items are objects ({ id, manifestPaths }) — renderWarningItems() renders the
59
+ // "defined in: <pom.xml>" line for that shape (same as the private-libs warning).
60
+ const byKey = new Map();
59
61
  for (const d of unresolved) {
60
62
  const k = `${d.groupId}:${d.artifactId}`;
61
- if (seenKeys.has(k)) continue;
62
- seenKeys.add(k);
63
- items.push(`${k}${d.version ? " (" + d.version + ")" : " (no version resolved)"}`);
63
+ const paths = (d.manifestPaths && d.manifestPaths.length) ? d.manifestPaths
64
+ : (d.pomPaths && d.pomPaths.length) ? d.pomPaths
65
+ : [];
66
+ let entry = byKey.get(k);
67
+ if (!entry) {
68
+ entry = { id: `${k}${d.version ? " (" + d.version + ")" : " (no version resolved)"}`, manifestPaths: [] };
69
+ byKey.set(k, entry);
70
+ }
71
+ for (const p of paths) if (!entry.manifestPaths.includes(p)) entry.manifestPaths.push(p);
64
72
  }
73
+ const items = [...byKey.values()];
65
74
  warnings.push({
66
75
  type: "unresolved-versions",
67
76
  count: items.length,
package/lib/ui.js CHANGED
@@ -17,11 +17,12 @@ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇",
17
17
  const TITLE_A = "fad-checker";
18
18
  const TITLE_B = "Autonomous Dependency Checker";
19
19
 
20
- function banner() {
21
- const raw = `${TITLE_A} · ${TITLE_B}`;
20
+ function banner(version) {
21
+ const ver = version ? `v${version}` : "";
22
+ const raw = `${TITLE_A} ${ver} · ${TITLE_B}`.replace(" ", " ");
22
23
  const bar = "─".repeat(raw.length + 2);
23
24
  console.log(chalk.cyan(`\n╭${bar}╮`));
24
- console.log(chalk.cyan("│ ") + chalk.bold.white(TITLE_A) + chalk.cyan(" · ") + chalk.whiteBright(TITLE_B) + chalk.cyan(" │"));
25
+ console.log(chalk.cyan("│ ") + chalk.bold.white(TITLE_A) + (ver ? " " + chalk.dim(ver) : "") + chalk.cyan(" · ") + chalk.whiteBright(TITLE_B) + chalk.cyan(" │"));
25
26
  console.log(chalk.cyan(`╰${bar}╯`));
26
27
  }
27
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Scan ALL Maven, npm, Yarn, Composer, Python, C#/.NET, Go & Ruby dependencies — plus embedded JARs (fat-jars/war/ear) — in a source tree ONE SHOT without mvn/python/etc — CVE (EPSS/KEV-prioritised), EOL, obsolete, outdated & licenses, with SBOM/CSAF/SARIF/JSON exports, CI gating and fix recos",
5
5
  "keywords": [
6
6
  "sca",