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 +32 -117
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +1 -0
- package/fad-checker.js +48 -5
- package/lib/codecs/npm.codec.js +2 -2
- package/lib/cve-report.js +65 -20
- package/lib/embedded.js +57 -0
- package/lib/json-export.js +7 -1
- package/lib/outdated.js +19 -7
- package/lib/retire.js +95 -10
- package/lib/scan-completeness.js +15 -6
- package/lib/ui.js +4 -3
- package/package.json +1 -1
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"
|
|
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
|
-
•
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
fad-checker --add-repo
|
|
327
|
-
fad-checker --
|
|
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
|
-
|
|
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`
|
|
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
|
|
374
|
-
FAD_CHECKER_ENV='--fail-on high --no-nuget
|
|
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
|
-
|
|
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 --
|
|
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("--
|
|
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
|
-
|
|
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
|
-
|
|
902
|
-
|
|
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)
|
package/lib/codecs/npm.codec.js
CHANGED
|
@@ -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>${
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
${
|
|
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
|
|
1524
|
-
// part of the manifestPath before the first "!/"),
|
|
1525
|
-
//
|
|
1526
|
-
function renderEmbeddedChapter(
|
|
1527
|
-
if (!
|
|
1528
|
-
const
|
|
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
|
|
1531
|
-
|
|
1532
|
-
|
|
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
|
-
|
|
1538
|
-
|
|
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,
|
package/lib/embedded.js
ADDED
|
@@ -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 };
|
package/lib/json-export.js
CHANGED
|
@@ -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]
|
|
73
|
+
return withOrigin(EOL_MAPPING.by_composer_name?.[full], "composer-name", full);
|
|
66
74
|
}
|
|
67
75
|
if (dep.ecosystem === "pypi") {
|
|
68
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 =
|
|
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
|
|
323
|
-
|
|
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,
|
package/lib/scan-completeness.js
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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.
|
|
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",
|