fad-checker 2.4.4 → 2.4.6

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/AI_POLICY.md ADDED
@@ -0,0 +1,74 @@
1
+ # AI policy
2
+
3
+ ## How this project is built
4
+
5
+ `fad-checker` is based on a personal project then enhanced with heavy use of Claude Code. `CLAUDE.md` in the repo root is exactly
6
+ what it looks like: the orientation map the assistant works from.
7
+
8
+ I would rather state that plainly than have someone discover it and wonder what else was not
9
+ said. For a security tool the provenance of the code is a fair question, so here is what the
10
+ work is actually held to.
11
+
12
+ ## What the code is held to
13
+
14
+ None of this is a claim about how the code was written. All of it is checkable by you:
15
+
16
+ - **606 tests**, run with `npm test`. No test touches the network.
17
+ - **`test/offline-guarantee.test.js`** is a tripwire: it installs a fetcher that throws if
18
+ anything reaches for the network under `--offline`, on a cold cache. The zero-network
19
+ guarantee is a test, not a promise. It is also reproducible outside the suite:
20
+ `unshare -rn node fad-checker.js -s ./proj --offline` runs in a namespace with no network
21
+ interface at all.
22
+ - **Incremental history.** The work is spread across the commit log, not dropped in one
23
+ initial commit.
24
+ - **Measured, not asserted.** Coverage numbers in the docs come from comparison against a
25
+ Snyk baseline on a real multi-module project, with the methodology stated where they appear.
26
+ - **Fixtures come from real-world shapes.** Parsing behaviour is locked by a fixture in
27
+ `test/fixtures/`, derived from a manifest layout that actually occurs, not from an
28
+ invented one.
29
+
30
+ The honest counterweight: this is a young single-maintainer project and it has false positives
31
+ and false negatives. The [README says so](README.md), and the
32
+ [accuracy issue template](.github/ISSUE_TEMPLATE/false_positive.yml) exists because those
33
+ reports are the most valuable thing the project can receive.
34
+
35
+ ## Where review actually mattered
36
+
37
+ One example, because it is the kind of failure that matters for a scanner.
38
+
39
+ The first implementation of per-module Maven version mediation
40
+ (`lib/version-overlay.js`) re-resolved each module using only that module's own
41
+ `<dependencies>`. That is wrong: Maven inherits `<dependencies>` from the parent chain into
42
+ every child, where they stay direct and beat any transitive of the same coordinate. The
43
+ consequence was that a parent-declared, reactor-wide remediation was invisible, so the overlay
44
+ "recovered" the very version that remediation overrides, and reported CVEs against a version
45
+ no classpath holds.
46
+
47
+ Fabricating findings is worse for a security tool than missing them. The fix walks the local
48
+ parent chain, and `test/version-overlay-inherited-direct.test.js` locks the behaviour in both
49
+ directions: the masked version must surface, and the overridden one must not.
50
+
51
+ The general rule this project follows: a plausible-looking finding is not a finding. Unresolved
52
+ versions are excluded from matching rather than assumed vulnerable, CPE configurations are
53
+ cross-checked against the actual version, and there is no `!version means affected` shortcut
54
+ anywhere in the matcher.
55
+
56
+ ## What is expected of contributions
57
+
58
+ The same bar, regardless of how the code was produced:
59
+
60
+ - A test that fails before the change and passes after it.
61
+ - For parsing changes, a fixture derived from a real-world shape.
62
+ - No claim in the docs that is not backed by something a reader can run.
63
+ - You understand the change well enough to explain it in review without assistance.
64
+
65
+ Use whatever tools you like to get there. What is not welcome is a change whose author cannot
66
+ answer questions about it, because reviewing that costs more than writing it did. See
67
+ [`CONTRIBUTING.md`](CONTRIBUTING.md).
68
+
69
+ ## Reports generated by this tool
70
+
71
+ `fad-checker` uses **no LLM at runtime**. Findings come from public vulnerability databases
72
+ (CVEProject, OSV.dev, NVD), registry metadata and deterministic parsers. Severity, priority and
73
+ version matching are computed, not generated. Nothing in a report is written by a language
74
+ model, and the tool makes no network call to any AI service, ever.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,178 @@ This project adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ### Added
9
+ - **`--nvd-cpe-match` (opt-in, off by default): match dependencies against NVD's CPE version
10
+ ranges.** OSV/GHSA declare affected ranges per release *branch*; NVD declares them for every
11
+ affected branch. For `CVE-2020-9546`, OSV covers 2.9.0–2.9.10.4 while NVD also covers
12
+ 2.0.0–2.7.9.7 — so `jackson-databind:2.5.2` is affected and never got a fix. fad already had
13
+ that data in its NVD cache and only ever used it **subtractively**, to filter false positives.
14
+ This uses it additively, restricted to coordinates with an **unambiguous 1:1** entry in
15
+ `data/cpe-coord-map.json` (no name heuristics), and only for CVEs already enriched, so it adds
16
+ no network path.
17
+
18
+ **It is off by default because its measured precision is poor, and the reason is structural.**
19
+ On Apache Dubbo 2.7.8 it adds 76 findings of which **9 (12%) are corroborated by Snyk**. CPE
20
+ products are *framework*-level (`spring_framework`, `netty`, `log4j`) while Maven coordinates
21
+ are *artifact*-level, so a framework CVE lands on every artifact of that framework —
22
+ `CVE-2016-1000027` is a spring-web flaw and CPE puts it on spring-core, `CVE-2019-20444` is
23
+ netty-codec-http and CPE puts it on netty-common. Curation cannot fix a granularity mismatch;
24
+ allowing the map's deliberate 1:N entries made it worse still (262 findings, 8% corroborated).
25
+ Shipped as a triage aid ("what might I be missing?"), never as a default. Locked by
26
+ `test/nvd-cpe-match.test.js`, including a test asserting that a coordinate with no curated
27
+ entry is not matched even when the name heuristic would have accepted it.
28
+
29
+ The investigation behind it also settled what fad's 118 benchmark misses actually are, and
30
+ the answer is not flattering: **they are real misses, not the other scanner's noise.** All 87
31
+ public-CVE ones were checked against NVD — 7 confirmed, 13 where NVD's own range disagrees,
32
+ 67 where NVD names no CPE for the artifact. Both minorities were traced. The "NVD is silent"
33
+ bulk are public-database coverage gaps: `CVE-2023-6481` on `logback-classic@1.2.2` exists in
34
+ OSV with **no Maven package binding at all** (only a GIT range), while its own fixed-version
35
+ list `1.2.12, 1.3.13, 1.4.13` shows the 1.2.x branch was affected and fixed at 1.2.12 — so
36
+ 1.2.2 is vulnerable and no public-source scanner can see it. The "NVD contradicts" cases are
37
+ NVD contradicting itself: sibling jackson-databind gadget CVEs published weeks apart declare
38
+ `2.0.0–2.7.9.7 / 2.8.0–2.8.11.6 / 2.9.0–2.9.10.4` (CVE-2020-9546) versus `2.9.0–2.9.10.4`
39
+ alone (CVE-2020-10672). Public advisory data declares ranges per release *branch* and old
40
+ unpatched branches are routinely absent; a hand-curated commercial database fills that in and
41
+ aggregating public sources does not reproduce it. Documented in `docs/BENCHMARK.md` rather
42
+ than left as "not yet diagnosed".
43
+
8
44
  ### Fixed
45
+ - **An imported BOM's `<properties>` leaked into the importing project — and won.**
46
+ `<scope>import</scope>` imports a BOM's `<dependencyManagement>` and **nothing else**: the
47
+ BOM resolves its managed versions in its own property context, and its `<properties>` never
48
+ become the importer's. `core.js` merged them, and merged them so the BOM won
49
+ (`{...merged.properties, ...imported.properties}`), so a BOM silently redefined the
50
+ importing project's own property values. On Apache Dubbo 2.7.8 the reactor root sets
51
+ `<hibernate_validator_version>5.2.4.Final</hibernate_validator_version>` and
52
+ `dubbo-dependencies-bom` redefines it to `5.4.1.Final`, so `dubbo-filter-validation`'s
53
+ `<version>${hibernate_validator_version}</version>` resolved to the wrong version — and a
54
+ different version is a different CVE set. (`mvn dependency:tree` reports
55
+ `hibernate-validator:jar:5.2.4.Final:test` for that module.) The BOM's managed entries are
56
+ now interpolated against the BOM's own properties at the import boundary and the properties
57
+ are dropped, mirroring what `transitive.js#effectivePom` already did for EXTERNAL import
58
+ BOMs. Locked by `test/bom-property-leak.test.js`, which guards both directions: the leak
59
+ must stop **and** importing a BOM must still supply managed versions.
60
+ - **A version declared only at test scope is now reported as dev.** `isDev` lives on the
61
+ coord-wide record, so a version declared solely at `<scope>test</scope>` inherited the
62
+ coordinate's production flag whenever the same coordinate was production at some other
63
+ version — counting toward the production total and the `--fail-on` gate. Per-version scopes
64
+ are now recorded next to per-version paths (`versionScopes`, mirror of `versionPaths`), and
65
+ attribution applies the same widest-wins rule already used for overlay-recovered versions.
66
+ On Dubbo, `hibernate-validator:5.2.4.Final` moves to the dev chapter, attributed to
67
+ `dubbo-filter-validation` — exactly what Maven reports.
68
+
69
+ Air-gapped recall on the public benchmark reaches **657/657 (100%)** of OSV-Scanner's own
70
+ online finding set, up from 653.
71
+
72
+ ### Changed
73
+ - **The benchmark now measures every scanner at full capability, not just air-gapped.**
74
+ `docs/BENCHMARK.md` carries two tables, because they answer different questions. **Full
75
+ capability** (all five online, best configuration, populated `~/.m2`, union 908 pairs):
76
+ fad-checker 790 (87.0%), OSV-Scanner 657, Snyk 603, Trivy 546, Grype+Syft 45. **No tool
77
+ finds everything, fad included** — its 118 misses all come from Snyk, 30 of them under a
78
+ proprietary `SNYK-*` id no public database carries, and **88 genuine public-CVE misses that
79
+ remain undiagnosed**. **No network** (`unshare -rn`, against OSV-Scanner's online output):
80
+ fad 657/657, Grype+Syft 45, Trivy 40, OSV-Scanner 37.
81
+ Also documented: Trivy's result is identical online and with a fully populated `~/.m2` (the
82
+ local repository substitutes for the network entirely), Grype+Syft does not move at all
83
+ between default and fully-enabled configuration, and the `settings.xml` mirror trick for
84
+ reproducing the run when Maven Central rate-limits the IP.
85
+ - **The per-module overlay could not recover a version held only on a TEST classpath.**
86
+ The overlay exists because the global transitive pass dedupes by `g:a` across the whole
87
+ reactor and keeps one version per coordinate — but it hardcoded
88
+ `includedScopes: ["compile","runtime","provided"]`, so a version reachable only through a
89
+ test-scoped dependency was structurally unreachable. Measured on Apache Dubbo 2.7.8, this
90
+ one omission accounted for **every one** of the 78 findings OSV-Scanner reported that fad
91
+ missed (`jackson-databind:2.8.4:test` in dubbo-registry-sofa,
92
+ `hibernate-validator:5.2.4.Final:test` in dubbo-filter-validation, `okhttp:3.11.0` /
93
+ `okio:1.14.0` in dubbo-configcenter-apollo, `commons-compress:1.18:test` in
94
+ dubbo-remoting-etcd3 — each verified against `mvn dependency:tree`).
95
+ Air-gapped recall on that project: **579 → 653 of 657 (88.1% → 99.4%)**, production
96
+ findings unchanged at 651, dev findings 11 → 147, **zero production finding lost**.
97
+ - **A version is now dev only when EVERY module resolving it does so at test scope.**
98
+ On Dubbo, `jackson-databind:2.10.4` is test-scoped in `dubbo-config-spring` but
99
+ **compile**-scoped in `dubbo-configcenter-nacos`. Reading the first recorded provenance
100
+ called the version dev and dropped a genuine production finding out of the count and out
101
+ of the `--fail-on` gate.
102
+ - **The overlay records provenance per module even when the version is already known.**
103
+ It used to `continue` on the first module to contribute a version, so a second module
104
+ resolving the same version at a different scope left no trace at all — which is exactly
105
+ what made the previous item invisible.
106
+ - **A DECLARED version now wins over any transitive provenance for the same version.**
107
+ `xstream:1.4.10` is declared outright in `dubbo-registry-eureka` *and* reached as a
108
+ test-scoped transitive of `dubbo-config-api`; letting the transitive provenance win
109
+ demoted 35 findings, one of them KEV, into the dev chapter. A manifest that writes
110
+ `<version>` for a coordinate is the authority on that version.
111
+
112
+ ### Previously fixed
113
+ - **The transitive closure of a test-scoped dependency was never scanned.** Maven's scope
114
+ matrix says `test → compile = test`: the compile dependencies of a test-scoped dependency
115
+ are on the test classpath, and so are theirs, recursively (only `test → test` is omitted).
116
+ `expandWithTransitives` passed test-scoped roots into resolution (`includeTestDeps`, on
117
+ unless `--ignore-test`) but `resolveTransitiveDeps` then filtered accepted propagated
118
+ scopes to `compile/runtime/provided`, so **every child of a test root was discarded at the
119
+ first hop**. Net effect: the dev chapter only ever listed *directly declared* test
120
+ dependencies, never their transitives. On Apache Dubbo 2.7.8 this hid
121
+ `spring-boot:1.5.17.RELEASE` and `spring-boot-autoconfigure:1.5.17.RELEASE`, four hops down
122
+ `registry-test → registry-server-integration → spring-boot-starter`, all of which
123
+ `mvn dependency:tree` reports at scope=test.
124
+ - **Scope is now widened, never narrowed, when a coord is reached by several paths.** This is
125
+ the half of the fix above that matters most. The traversal dedupes by `g:a` and keeps the
126
+ first chain walked, so marking everything under a test root as dev let **BFS order decide
127
+ the scope**: a coordinate reachable from *both* a test root and a compile root got stamped
128
+ test if the test path happened to be walked first. Measured: 6 production findings
129
+ (`spring-core:4.3.16.RELEASE`, `commons-lang:2.6`, …) silently moved into the dev chapter,
130
+ dropping out of the production count **and out of the `--fail-on` gate**. A false negative
131
+ on the production classpath is worse than the gap being fixed. A revisit now upgrades
132
+ `test` to the wider propagated scope and never the reverse. Locked by
133
+ `test/transitive-test-scope.test.js` (6 tests, including the both-paths case and the
134
+ `test → test` omission).
135
+ Net on Dubbo: **575 → 579** recovered pairs, production findings **unchanged at 650**, dev
136
+ findings 7 → 11.
137
+ - **Maven hard-pin versions (`[1.2.3]`) are now normalised to the bare version.**
138
+ Maven's `[x]` syntax means *exactly* x — a concrete version wearing range brackets —
139
+ and real upstream POMs use it (`io.grpc:grpc-netty:1.22.1` declares
140
+ `<version>[4.1.35.Final]</version>`). fad kept the brackets verbatim, so the coordinate
141
+ was wrong **everywhere downstream**: the report, the purl, and every SBOM/CSAF/SARIF/JSON
142
+ export carried `netty-codec-http2@[4.1.35.Final]`, which cannot be joined with any other
143
+ tool's output for the same dependency. Fixed by `lib/maven-version.js#normalizeHardPin`,
144
+ applied on both paths that produce a version — `cve-match.js#resolveDepVersion` (declared
145
+ deps, after `${…}` interpolation, so `[${netty.version}]` works) and `lib/transitive.js`
146
+ (deps read out of upstream POMs, which is where this actually came from). A **genuine
147
+ range keeps its brackets**: choosing a version out of `[1.0,2.0)` is resolution, not
148
+ normalisation, and it must keep surfacing as unresolved rather than silently becoming
149
+ concrete. Found by the new public benchmark, where it cost **9 recovered findings**
150
+ (566 → 575 of OSV-Scanner's reference set on Apache Dubbo 2.7.8). Note that the OSV cache
151
+ is keyed by coordinate **and** version, so this fix invalidates entries warmed under the
152
+ old string — an offline re-run needs a cache re-warm to see the corrected coordinate.
153
+ - **Docs referenced a `--transitive` flag that does not exist.** Transitive resolution is
154
+ **on by default**; `--no-transitive` disables it. Corrected in `ARCHITECTURE.md` and
155
+ `COMPARISON.md`.
156
+
157
+ ### Added
158
+ - **`docs/BENCHMARK.md` — a reproducible air-gapped recall benchmark.** Replaces the
159
+ unverifiable private-project figure that headlined the README. Measured on **Apache Dubbo
160
+ 2.7.8** (105-module reactor, pinned commit), with both scanners run under `unshare -rn` in
161
+ a namespace with **no network interface**, and graded against **OSV-Scanner's own online
162
+ output** as the reference set (657 distinct `package@version | vulnerability` pairs) rather
163
+ than against fad's own notion of a finding: **fad-checker recovers 575 (87.5%)**,
164
+ **OSV-Scanner recovers 37 (5.6%)**. Documents the exact commands, the tool versions, the
165
+ 82 pairs fad misses **and why** (version-mediation divergence, plus two genuinely
166
+ unresolved `spring-boot` coordinates), and the caveats — a warmed cache is required, and
167
+ one project is one shape.
168
+
169
+ ### Changed
170
+ - `docs/COMPARISON.md`: four competitor cells corrected after re-verification against
171
+ upstream docs and source. Syft **does** have Maven transitive resolution
172
+ (`java.resolve-transitive-dependencies`, opt-in, off by default); Trivy consults a local
173
+ `~/.m2` before the network; Trivy **does** report end-of-service-life, but only for OS
174
+ distributions (the EOL row is now scoped to *application* frameworks); and the
175
+ "scan without exposing the codebase" row now concedes the SBOM-then-scan-online route,
176
+ keeping only the two differences that are sourceable. Adds a version stamp for every tool
177
+ compared and an explicit note that no ⚠️/❌ cell means "unmaintained".
178
+
179
+ ### Previously fixed
9
180
  - **External `<parent>` POMs (spring-boot-starter-parent) now backfill their managed
10
181
  versions.** A versionless dep whose version is inherited from an external `<parent>`
11
182
  (e.g. `spring-boot-starter-actuator` under `spring-boot-starter-parent`, whose own
@@ -0,0 +1,61 @@
1
+ # Contributing
2
+
3
+ Thanks for looking. This is a young single-maintainer project, so the most valuable
4
+ contribution isn't necessarily code — it's **telling me where the output is wrong**.
5
+
6
+ ## The most useful thing you can do
7
+
8
+ Run it on a real project and report what it got wrong:
9
+
10
+ ```bash
11
+ npx fad-checker -s ./your-project
12
+ ```
13
+
14
+ A [false positive / false negative report](https://github.com/9pings/fad-checker/issues/new?template=false_positive.yml)
15
+ with the coordinate and the manifest snippet that produced it is worth more than a feature
16
+ request. Dependency resolution has a long tail of real-world shapes — inherited BOMs, profile
17
+ matrices, `replace` directives, vendored jars — and the only way to cover them is to be shown
18
+ one that breaks.
19
+
20
+ ## Development setup
21
+
22
+ Node ≥ 20, no other tooling required.
23
+
24
+ ```bash
25
+ npm install
26
+ npm test # 606 tests via node --test
27
+ node --test test/core.test.js # a single file
28
+ node fad-checker.js -s test/fixtures/monorepo-mixed --offline --no-report
29
+ ```
30
+
31
+ Building the single-file binaries needs [bun](https://bun.sh): `npm run build`.
32
+
33
+ ## Ground rules for changes
34
+
35
+ - **Tests must not touch the network.** Every fixture-driven test drives an in-memory
36
+ registry/fetcher. `test/offline-guarantee.test.js` is a tripwire that fails if any code path
37
+ reaches for the network under `--offline` — don't work around it.
38
+ - **Add a fixture, not just an assertion.** New parsing behaviour belongs in
39
+ `test/fixtures/`, next to the shapes that already live there.
40
+ - **A parse failure must never kill the run.** One malformed manifest logs a warning and the
41
+ scan continues; there is no `process.exit(1)` mid-pipeline.
42
+ - **New ecosystem = new codec.** Implement the interface in
43
+ `lib/codecs/codec.interface.js`, register it, and the orchestrator needs no edits. See
44
+ [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
45
+ - Commit messages follow Conventional Commits (`fix(maven): …`, `feat(gradle): …`).
46
+ - [`CLAUDE.md`](CLAUDE.md) is the code-level orientation map — conventions, invariants and the
47
+ reasoning behind the non-obvious ones. Read it before a non-trivial change; it will save you
48
+ from "simplifying" something that's load-bearing.
49
+
50
+ ## On AI-assisted development
51
+
52
+ This codebase is written with heavy use of Claude Code, and I'm not going to pretend
53
+ otherwise — `CLAUDE.md` in the repo root is exactly what it looks like. What I ask of my own
54
+ changes and of contributions is the same either way: a test that fails before and passes
55
+ after, a fixture derived from a real-world shape, and no claim in the docs that isn't backed
56
+ by something you can run. If you find a place where the code doesn't meet that bar, that's a
57
+ bug report I want.
58
+
59
+ ## Reporting a vulnerability in fad-checker itself
60
+
61
+ See [`SECURITY.md`](SECURITY.md) — please report privately, not as a public issue.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pp9Ping
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,34 +2,35 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/fad-checker.svg)](https://www.npmjs.com/package/fad-checker)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/fad-checker.svg)](https://www.npmjs.com/package/fad-checker)
5
- [![license](https://img.shields.io/npm/l/fad-checker.svg)](https://github.com/9pings/fad-checker/blob/main/package.json)
5
+ [![license](https://img.shields.io/npm/l/fad-checker.svg)](LICENSE)
6
6
  [![node](https://img.shields.io/node/v/fad-checker.svg)](https://nodejs.org)
7
+ [![CI](https://github.com/9pings/fad-checker/actions/workflows/ci.yml/badge.svg)](https://github.com/9pings/fad-checker/actions/workflows/ci.yml)
7
8
 
8
9
  > **F**abulous **A**utonomous **D**ependency **C**hecker<br>
9
10
  > AKA **F**uckin' **A**utonomous **D**ependency **C**hecker<br>
10
11
 
11
- `fad-checker` audits **Maven · Gradle · npm · Yarn · pnpm · Composer · PyPI · NuGet · Go · Ruby**, vendored JavaScript, committed native binaries and cryptographic material (certificates & private/public keys) in any source tree multi-module, monorepo, polyglot and produces a self-contained **HTML + Word report** (CVE prioritised by EPSS + CISA KEV, EOL, obsolete, outdated, licenses) plus **CycloneDX SBOM / CSAF VEX / SARIF / JSON** exports. **No build tools, no Docker, no network needed** it reads lockfiles and manifests straight off disk.
12
+ `fad-checker` audits **Maven · Gradle · npm · Yarn · pnpm · Composer · PyPI · NuGet · Go · Ruby**, vendored JavaScript, committed native binaries and cryptographic material (certificates & private/public keys) in any source tree; multi-module, monorepo, polyglot; and produces a self-contained **HTML + Word report** (CVE prioritised by EPSS + CISA KEV, EOL, obsolete, outdated, licenses) plus **CycloneDX SBOM / CSAF VEX / SARIF / JSON** exports. **No build tools, no Docker, no network needed**; it reads lockfiles and manifests straight off disk.
12
13
 
13
14
  🌐 **[Project site & docs →](https://9pings.github.io/fad-checker/)**
14
15
 
15
16
  > [!WARNING]
16
- > **Young project expect rough edges.** fad-checker is new and under active development, so it may still contain bugs (including false positives and false negatives). Treat its output as a strong first pass, **double-check anything critical**, and please [report issues](https://github.com/9pings/fad-checker/issues) they get fixed fast.
17
+ > **Young project; expect rough edges.** fad-checker is new and under active development, so it may still contain bugs (including false positives and false negatives). Treat its output as a strong first pass, **double-check anything critical**, and please [report issues](https://github.com/9pings/fad-checker/issues); they get fixed fast.
17
18
 
18
- <p align="center"><img src="docs/assets/demo.gif" alt="fad-checker animated terminal demo a [n/N] checklist warming each vulnerability database, then CVE findings coloured by severity with KEV badges" height="600"></p>
19
+ <p align="center"><img src="docs/assets/demo.gif" alt="fad-checker animated terminal demo; a [n/N] checklist warming each vulnerability database, then CVE findings coloured by severity with KEV badges" height="600"></p>
19
20
 
20
21
  ## Features
21
22
 
22
- - **10 ecosystems in one pass** Maven, **Gradle**, npm/Yarn/pnpm, Composer (PHP), PyPI, NuGet, Go, Ruby; plus **vendored JS** (retire.js), committed **native binaries** (`.dll`/`.exe`/`.so`/`.dylib`, identified by checksum via deps.dev + CIRCL) and **embedded JARs** (fat-jars/war/ear, unzipped in-memory).
23
- - **Crypto material** committed **certificates** (X.509, PEM/DER), **private &amp; public keys** (PEM / OpenSSH every algorithm / PuTTY / PGP / one-line SSH) and **keystores** (JKS/JCEKS/PKCS#12). Each key is labelled **private** (a committed secret → critical) or **public**; certs are checked for **expiry, weak key (RSA<2048), weak signature (MD5/SHA1) and self-signed** all parsed offline with the built-in X.509 parser, no network. `--no-certs` to disable.
24
- - **No build tools** reads `pom.xml`, `build.gradle(.kts)`/`gradle.lockfile`/`libs.versions.toml`, `package-lock`/`yarn.lock`/`pnpm-lock`, `composer.lock`, `poetry`/`Pipfile`/`uv`/`pdm` locks, `packages.lock.json`/`*.csproj`, `go.mod`, `Gemfile.lock` directly. No `mvn`/`gradle`/`npm install`/`pip`/`dotnet restore`/`go build`/`bundle`, no `node_modules/`. → [how it stays build-free](docs/COMPARISON.md#how-its-autonomous-no-build-tools)
25
- - **CVE, merged & prioritised** CVEProject + OSV.dev + NVD, CPE/version cross-checked to cut false positives, ranked **CISA KEV → EPSS → CVSS**.
26
- - **Per-module Maven version mediation** recovers vulnerable transitive versions that a global `<dependencyManagement>` pin hides in another module (lifted Snyk-corroborated coverage **156 181** on a real 25-module reactor, finding CVEs a single Snyk scan missed).
27
- - **Air-gapped** **zero network under `--offline`** (regression-tested), offline Maven transitive resolution, and `--osv-db` for cache-independent offline OSV recall. → [Air-gapped](#air-gapped-audits)
28
- - **Supply-chain risk** known-**malicious** advisories (`MAL-`, always block the CI gate) + suspected **typosquats** (`--typosquat`).
29
- - **Lifecycle** EOL (endoflife.date), obsolete/deprecated, outdated across every ecosystem.
30
- - **Licenses** *(opt-in `--licenses`)* SPDX-normalised, copyleft/proprietary flagged.
31
- - **Audit-grade & reproducible** every report carries a **provenance manifest** (data-source freshness + run config) and a **Methodology, data sources & limitations** chapter; artifacts ship a **`SHA256SUMS`** integrity manifest (`sha256sum -c`); **differential audits** diff against a prior run (`--baseline`, or `fad diff a.json b.json`) and CI can gate on *new* findings (`--fail-on-new`).
32
- - **Outputs & CI** HTML + Word `.doc`, CycloneDX 1.6 SBOM, CSAF 2.0 VEX, SARIF 2.1.0, JSON; gate with `--fail-on` / `--fail-on-new`, triage with `--ignore`/`--vex`. Private registries for Maven, npm, PyPI, Ruby, Go, **NuGet** and **Composer**.
23
+ - **10 ecosystems in one pass**; Maven, **Gradle**, npm/Yarn/pnpm, Composer (PHP), PyPI, NuGet, Go, Ruby; plus **vendored JS** (retire.js), committed **native binaries** (`.dll`/`.exe`/`.so`/`.dylib`, identified by checksum via deps.dev + CIRCL) and **embedded JARs** (fat-jars/war/ear, unzipped in-memory).
24
+ - **Crypto material**; committed **certificates** (X.509, PEM/DER), **private &amp; public keys** (PEM / OpenSSH every algorithm / PuTTY / PGP / one-line SSH) and **keystores** (JKS/JCEKS/PKCS#12). Each key is labelled **private** (a committed secret → critical) or **public**; certs are checked for **expiry, weak key (RSA<2048), weak signature (MD5/SHA1) and self-signed**; all parsed offline with the built-in X.509 parser, no network. `--no-certs` to disable.
25
+ - **No build tools**; reads `pom.xml`, `build.gradle(.kts)`/`gradle.lockfile`/`libs.versions.toml`, `package-lock`/`yarn.lock`/`pnpm-lock`, `composer.lock`, `poetry`/`Pipfile`/`uv`/`pdm` locks, `packages.lock.json`/`*.csproj`, `go.mod`, `Gemfile.lock` directly. No `mvn`/`gradle`/`npm install`/`pip`/`dotnet restore`/`go build`/`bundle`, no `node_modules/`. → [how it stays build-free](docs/COMPARISON.md#how-its-autonomous-no-build-tools)
26
+ - **CVE, merged & prioritised**; CVEProject + OSV.dev + NVD, CPE/version cross-checked to cut false positives, ranked **CISA KEV → EPSS → CVSS**.
27
+ - **Per-module Maven version mediation**; recovers vulnerable transitive versions that a global `<dependencyManagement>` pin hides in another module, applying Maven's own nearest-wins semantics per module rather than resolving the whole reactor as one tree.
28
+ - **Air-gapped**; **zero network under `--offline`** (regression-tested), offline Maven transitive resolution, and `--osv-db` for cache-independent offline OSV recall. Benchmarked against OSV-Scanner, Snyk, Trivy and Grype+Syft on **six public projects across six ecosystems**: identical finding sets on npm, RubyGems and Composer (parity is the correct outcome when the graph is in the lockfile), and on Maven — where it is not — fad recovers **657/657** of OSV-Scanner's *online* result with **no network interface at all**, versus 45 / 40 / 37 for the others. At full capability no tool finds everything, fad included: it leads at 87% of a 908-pair union and still misses 118 that Snyk finds. → [Benchmark](docs/BENCHMARK.md) · [Air-gapped](#air-gapped-audits)
29
+ - **Supply-chain risk**; known-**malicious** advisories (`MAL-`, always block the CI gate) + suspected **typosquats** (`--typosquat`).
30
+ - **Lifecycle**; EOL (endoflife.date), obsolete/deprecated, outdated; across every ecosystem.
31
+ - **Licenses** *(opt-in `--licenses`)*; SPDX-normalised, copyleft/proprietary flagged.
32
+ - **Audit-grade & reproducible**; every report carries a **provenance manifest** (data-source freshness + run config) and a **Methodology, data sources & limitations** chapter; artifacts ship a **`SHA256SUMS`** integrity manifest (`sha256sum -c`); **differential audits** diff against a prior run (`--baseline`, or `fad diff a.json b.json`) and CI can gate on *new* findings (`--fail-on-new`).
33
+ - **Outputs & CI**; HTML + Word `.doc`, CycloneDX 1.6 SBOM, CSAF 2.0 VEX, SARIF 2.1.0, JSON; gate with `--fail-on` / `--fail-on-new`, triage with `--ignore`/`--vex`. Private registries for Maven, npm, PyPI, Ruby, Go, **NuGet** and **Composer**.
33
34
 
34
35
  📖 **[Usage & all flags](docs/USAGE.md)** · **[Architecture](docs/ARCHITECTURE.md)** · **[Comparison vs other tools](docs/COMPARISON.md)** · **[Data sources](docs/DATA-SOURCES.md)**
35
36
 
@@ -40,18 +41,25 @@ npm install -g fad-checker
40
41
  fad-checker -s ./my-project # → ./fad-checker-report/cve-report.html
41
42
  ```
42
43
 
43
- A free [NVD API key](https://nvd.nist.gov/developers/request-an-api-key) (instant) gives 10× faster enrichment: `fad-checker --set-nvd-key YOUR_KEY`. A few common runs full list via `fad-checker --help` or [docs/USAGE.md](docs/USAGE.md):
44
+ A free [NVD API key](https://nvd.nist.gov/developers/request-an-api-key) (instant) gives 10× faster enrichment: `fad-checker --set-nvd-key YOUR_KEY`. A few common runs; full list via `fad-checker --help` or [docs/USAGE.md](docs/USAGE.md):
44
45
 
45
46
  ```bash
46
47
  fad-checker -s ./proj -e "^com\.acme\." # exclude private libs (coord regex)
47
48
  fad-checker -s ./proj -t ../clean -e "^com\.acme\." --snyk # cleaned POM tree + merge Snyk
48
- fad-checker -s ./proj --offline # fully offline (zero network)
49
+ fad-checker -s ./proj --offline # fully offline (zero network, needs a warmed cache)
49
50
  fad-checker -s ./proj --osv-db --typosquat # offline-complete OSV + typosquat
50
51
  fad-checker -s ./proj --licenses --fail-on high # license chapter + CI gate
51
52
  fad-checker -s ./proj --report-json --baseline last.json --fail-on-new # differential audit: fail CI on NEW findings
52
53
  fad-checker diff last.json this.json # standalone diff of two findings JSONs
53
54
  ```
54
55
 
56
+ > [!IMPORTANT]
57
+ > **`--offline` reads the cache, it doesn't replace it.** On a *cold* cache there is nothing to
58
+ > match against, so an offline first run legitimately reports **0 CVE / 0 EOL / 0 outdated**;
59
+ > that's an empty cache, not a clean project. Warm it once (a normal online run on any project,
60
+ > or `--import-cache`), then `--offline` returns the full result set with zero network calls.
61
+ > Air-gapped machines get their cache via [`--export-cache` / `--import-cache`](#air-gapped-audits).
62
+
55
63
  A single self-contained binary (no Node), from-source install and shell completion are in → [docs/USAGE.md](docs/USAGE.md).
56
64
 
57
65
  ## What it finds
@@ -61,51 +69,51 @@ The report is organised into **root chapters** (each grouping related sub-chapte
61
69
  | Chapter | Source | What it catches |
62
70
  | --- | --- | --- |
63
71
  | **0. Warnings** *(top)* | local heuristics | Missing lockfiles, unresolved Maven versions (BOM-managed), private libs not on Maven Central |
64
- | **Δ. Changes since baseline** *(top, with `--baseline`)* | diff vs prior JSON | New / fixed / unchanged findings per category + the list of **new production CVEs** for repeat audits and `--fail-on-new` CI gating |
65
- | **1. CVE** *(X direct, Y indirect, Z dev)* | CVEProject + OSV.dev + NVD + CPE | **1.1 Production** public CVE / GHSA in prod deps, per ecosystem, per manifest, **prioritised** by CISA KEV + EPSS + CVSS · **1.2 Vendored JS vulns** ([retire.js](https://retirejs.github.io/)) · **1.3 Dev** (`test`/`provided`, `dev`/`optional`/`peer`) · **1.4 Likely false positives** (CPE-filtered) |
66
- | **2. Unmanaged / unversioned components** | deps.dev + CIRCL (by checksum), retire.js, built-in X.509 | **2.1 Embedded binaries** CVEs in libs shipped inside committed `.jar`/`.war`/`.ear` (fat-jars, shaded uber-jars) · **2.2 Native binaries** (`.dll`/`.exe`/`.so`/`.dylib`) identified by hash, flagged should-be-managed / name≠checksum / unknown / malicious · **2.3 Vendored JavaScript** inventory (jQuery, Bootstrap, …) vulnerable *or not* · **2.4 Certificates &amp; key material** committed certs (expiry / weak key / weak signature / self-signed), **private vs public keys** (PEM/OpenSSH/PuTTY/PGP/SSH) and keystores, all parsed offline |
72
+ | **Δ. Changes since baseline** *(top, with `--baseline`)* | diff vs prior JSON | New / fixed / unchanged findings per category + the list of **new production CVEs**; for repeat audits and `--fail-on-new` CI gating |
73
+ | **1. CVE** *(X direct, Y indirect, Z dev)* | CVEProject + OSV.dev + NVD + CPE | **1.1 Production**; public CVE / GHSA in prod deps, per ecosystem, per manifest, **prioritised** by CISA KEV + EPSS + CVSS · **1.2 Vendored JS vulns** ([retire.js](https://retirejs.github.io/)) · **1.3 Dev** (`test`/`provided`, `dev`/`optional`/`peer`) · **1.4 Likely false positives** (CPE-filtered) |
74
+ | **2. Unmanaged / unversioned components** | deps.dev + CIRCL (by checksum), retire.js, built-in X.509 | **2.1 Embedded binaries**; CVEs in libs shipped inside committed `.jar`/`.war`/`.ear` (fat-jars, shaded uber-jars) · **2.2 Native binaries** (`.dll`/`.exe`/`.so`/`.dylib`) identified by hash, flagged should-be-managed / name≠checksum / unknown / malicious · **2.3 Vendored JavaScript** inventory (jQuery, Bootstrap, …) vulnerable *or not* · **2.4 Certificates &amp; key material**; committed certs (expiry / weak key / weak signature / self-signed), **private vs public keys** (PEM/OpenSSH/PuTTY/PGP/SSH) and keystores, all parsed offline |
67
75
  | **3. Maintenance / lifecycle** *(X EOL, Y obsolete, Z outdated)* | endoflife.date · curated + registry flags · Maven Central / npm / Packagist / PyPI / NuGet | **3.1 End-of-Life** frameworks · **3.2 Obsolete / deprecated / abandoned / yanked** · **3.3 Outdated** (newer version available, with release dates) |
68
76
  | **4. 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 |
69
77
  | **5. Fix Recommendations** | computed | Per-ecosystem pin recipes: Maven `<dependencyManagement>`, Gradle `constraints { }`, npm `overrides`, yarn `resolutions`, `composer require`, `pip install`, `dotnet add package` |
70
78
  | **6. Scan context & limitations** | provenance manifest + walk | **6.1 Scanned descriptors** (every manifest parsed) · **6.2 Ignored directories** (pruned paths + rule) · **6.3 Methodology, data sources & limitations** (data-source freshness, run config, explicit statement of **what fad-checker does *not* assess**) |
71
- | **Supply-chain risk** *(cross-cutting)* | OSV `MAL-…` + name heuristic | **Known-malicious** packages (always block the CI gate, any `--fail-on` level) and **suspected typosquats** (`--typosquat`: an npm/PyPI name one edit from a popular package `lodahs`↔`lodash`) |
79
+ | **Supply-chain risk** *(cross-cutting)* | OSV `MAL-…` + name heuristic | **Known-malicious** packages (always block the CI gate, any `--fail-on` level) and **suspected typosquats** (`--typosquat`: an npm/PyPI name one edit from a popular package; `lodahs`↔`lodash`) |
72
80
 
73
81
  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.
74
82
 
75
- <p align="center"><img src="docs/assets/report.png" alt="fad-checker HTML report executive summary with severity tiles and a detailed CVE table with CWE, descriptions and fix versions" width="900"></p>
83
+ <p align="center"><img src="docs/assets/report.png" alt="fad-checker HTML report; executive summary with severity tiles and a detailed CVE table with CWE, descriptions and fix versions" width="900"></p>
76
84
 
77
85
  ## Air-gapped audits
78
86
 
79
87
  > **Zero-data-sent guarantee.** Under `--offline`, fad-checker makes **no network calls
80
- > whatsoever** it reads only the warmed `~/.fad-checker/` caches and never transmits a
88
+ > whatsoever**; it reads only the warmed `~/.fad-checker/` caches and never transmits a
81
89
  > dependency, path or finding off the machine. It is regression-tested
82
90
  > (`test/offline-guarantee.test.js`, a tripwire fetcher that throws if touched) and
83
91
  > auditor-reproducible: `unshare -rn node fad-checker.js -s ./proj --offline …` runs it in a
84
92
  > namespace with **no network interface** and yields byte-identical findings. Unlike the
85
- > mainstream OSS scanners, fad also resolves the **Maven transitive graph** offline so on
93
+ > mainstream OSS scanners, fad also resolves the **Maven transitive graph** offline; so on
86
94
  > an air-gapped multi-module project it finds the transitive CVEs they can't.
87
95
 
88
96
  When the audited system is **offline / confidential** (typical of a regulated or air-gapped audit) it
89
97
  can't reach OSV / NVD / Maven Central / npm. Split the work across machines while keeping
90
98
  **zero environment information** off the secure enclave: an anonymized descriptor carries
91
- only **public package coordinates** no filesystem paths, no registry URLs, no
92
- hostnames/usernames and the **detailed report is produced back on the offline machine**.
99
+ only **public package coordinates**; no filesystem paths, no registry URLs, no
100
+ hostnames/usernames; and the **detailed report is produced back on the offline machine**.
93
101
 
94
102
  The transfer relies on a property of fad-checker's caches: they are keyed by *coordinate*
95
103
  or *vuln id*, never by path, so they are **machine-independent**. The online step just
96
104
  **warms the caches**; the offline step replays the scan and gets cache hits.
97
105
 
98
106
  ```bash
99
- # ── Phase 1 OFFLINE (audited machine): export the anonymized descriptor ──
107
+ # ── Phase 1; OFFLINE (audited machine): export the anonymized descriptor ──
100
108
  # Exclude private/internal packages with -e (offline we can't tell private from public).
101
109
  fad-checker -s ./proj -e "^(client|internal)\." --export-anonymized deps.json
102
110
  # → deps.json: public coordinates only. Review it before it leaves the enclave.
103
111
 
104
- # ── Phase 2 ONLINE (any machine, no source needed): warm the caches ──
112
+ # ── Phase 2; ONLINE (any machine, no source needed): warm the caches ──
105
113
  fad-checker --import-anonymized deps.json # scans coordinates → OSV/NVD/CVE/registry/EOL + retire signatures
106
114
  fad-checker --export-cache fad-cache.tar.gz # bundle the warmed ~/.fad-checker/
107
115
 
108
- # ── Phase 3 OFFLINE (audited machine): full report, all local context ──
116
+ # ── Phase 3; OFFLINE (audited machine): full report, all local context ──
109
117
  fad-checker --import-cache fad-cache.tar.gz
110
118
  fad-checker -s ./proj --offline # re-collect locally (real paths) + cache hits
111
119
  # → full HTML/.doc report with manifests & structure, generated inside the enclave.
@@ -121,18 +129,36 @@ What the descriptor (`fad-deps/1`) contains vs. drops:
121
129
  | scope, isDev | parent chains, lockfile type |
122
130
 
123
131
  The online phase report is itself path-free; vendored-JavaScript (retire.js) findings are
124
- produced **offline in phase 3**, since retire needs the actual `.js` files its signature
132
+ produced **offline in phase 3**, since retire needs the actual `.js` files; its signature
125
133
  DB is warmed online (phase 2) and carried by `--export-cache`. Full offline/cache control →
126
134
  [`docs/USAGE.md`](docs/USAGE.md).
127
135
 
128
136
  ## Docs
129
137
 
130
- - [`docs/USAGE.md`](docs/USAGE.md) every flag and workflow: offline/cache control, private registries, config files, recipes, safety rails.
131
- - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) internals: codecs, collection, matching, report pipeline.
132
- - [`docs/COMPARISON.md`](docs/COMPARISON.md) vs OSV-Scanner / Trivy / Grype / OWASP DC / Snyk, and how it stays build-free.
133
- - [`docs/DATA-SOURCES.md`](docs/DATA-SOURCES.md) — the public datasets fad-checker uses + their licenses.
134
- - [`CHANGELOG.md`](CHANGELOG.md) · [`CLAUDE.md`](CLAUDE.md) release history · code-level orientation for contributors.
138
+ - [`docs/USAGE.md`](docs/USAGE.md); every flag and workflow: offline/cache control, private registries, config files, recipes, safety rails.
139
+ - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); internals: codecs, collection, matching, report pipeline.
140
+ - [`docs/COMPARISON.md`](docs/COMPARISON.md); vs OSV-Scanner / Trivy / Grype / OWASP DC / Snyk, and how it stays build-free.
141
+ - [`docs/BENCHMARK.md`](docs/BENCHMARK.md) — reproducible air-gapped recall benchmark vs OSV-Scanner on a public 105-module project.
142
+ - [`docs/DATA-SOURCES.md`](docs/DATA-SOURCES.md); the public datasets fad-checker uses + their licenses.
143
+ - [`CHANGELOG.md`](CHANGELOG.md) · [`CLAUDE.md`](CLAUDE.md); release history · code-level orientation for contributors.
144
+
145
+ ## Contributing
146
+
147
+ The most useful contribution to a young scanner is **telling it where it's wrong**: run it on a
148
+ real project and file a [false positive / false negative report](https://github.com/9pings/fad-checker/issues/new?template=false_positive.yml)
149
+ with the coordinate and the manifest snippet that produced it. Dev setup, ground rules and the
150
+ codec extension point → [`CONTRIBUTING.md`](CONTRIBUTING.md). Vulnerabilities in fad-checker
151
+ itself → [`SECURITY.md`](SECURITY.md) (please report privately).
152
+
153
+ **On AI assistance:** this codebase is written with heavy use of Claude Code; [`CLAUDE.md`](CLAUDE.md)
154
+ in the repo root is exactly what it looks like. The bar it's held to is the one you can check
155
+ yourself: **606 tests** (`npm test`), the zero-network guarantee enforced by a tripwire test and
156
+ reproducible under `unshare -rn`, and coverage numbers measured against a Snyk baseline rather
157
+ than asserted. `fad-checker` itself uses **no LLM at runtime**; findings come from public
158
+ vulnerability databases and deterministic parsers, and no report text is generated. Full
159
+ statement, including where review actually caught a bad finding →
160
+ [`AI_POLICY.md`](AI_POLICY.md). Where the code doesn't meet that bar, that's a bug report I want.
135
161
 
136
162
  ## License
137
163
 
138
- MIT see [`LICENSE`](LICENSE).
164
+ MIT; see [`LICENSE`](LICENSE).
package/SECURITY.md ADDED
@@ -0,0 +1,60 @@
1
+ # Security policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Report vulnerabilities in `fad-checker` itself **privately**, via
6
+ [GitHub private security advisories](https://github.com/9pings/fad-checker/security/advisories/new).
7
+ If that isn't available to you, email `pp9Ping@gmail.com` with `fad-checker security` in the
8
+ subject.
9
+
10
+ Please don't open a public issue for a vulnerability. This is a single-maintainer project —
11
+ expect a first response within **7 days**, and a fix or a plan within **30 days** for anything
12
+ confirmed. Credit is given in the release notes unless you'd rather stay anonymous.
13
+
14
+ Findings *about your own dependencies* that fad-checker reported are not vulnerabilities in
15
+ this project — those belong in a normal issue (the
16
+ [false positive / false negative](https://github.com/9pings/fad-checker/issues/new?template=false_positive.yml)
17
+ template).
18
+
19
+ ## Supported versions
20
+
21
+ Only the latest released version on npm is supported. There are no backport branches.
22
+
23
+ ## Threat model
24
+
25
+ fad-checker parses **untrusted input**: manifests, lockfiles, archives and certificates from a
26
+ source tree you may not control. The parsing surface is where security bugs are expected, and
27
+ reports there are especially welcome. In particular:
28
+
29
+ - **Archive extraction** — `.jar`/`.war`/`.ear` are unzipped **in memory** (`fflate`), never to
30
+ disk, and nested archives are recursed to a bounded depth. Path traversal (zip-slip) has no
31
+ filesystem to reach, but resource exhaustion (zip bombs) is a valid report.
32
+ - **Certificate and key parsing** — X.509 goes through Node's built-in `crypto.X509Certificate`.
33
+ Key material is **classified and hashed, never decrypted**, and its contents are never written
34
+ to the report.
35
+ - **XML / YAML / TOML parsing** — `pom.xml` (xml2js), lockfiles (js-yaml, smol-toml). XXE and
36
+ entity-expansion reports are in scope.
37
+ - **Command execution** — the only subprocesses are `snyk` (opt-in `--snyk`), `retire`, and
38
+ `curl`/`unzip` for the CVE bundle download.
39
+
40
+ ## What leaves your machine
41
+
42
+ Under `--offline`, **nothing**: no network call is made at all, only the warmed
43
+ `~/.fad-checker/` caches are read. This is enforced by a regression test
44
+ (`test/offline-guarantee.test.js`, a tripwire fetcher that throws if touched) and is
45
+ reproducible in a network-free namespace:
46
+
47
+ ```bash
48
+ unshare -rn node fad-checker.js -s ./proj --offline
49
+ ```
50
+
51
+ Online, fad-checker sends **package coordinates and versions** to the data sources listed in
52
+ [`docs/DATA-SOURCES.md`](docs/DATA-SOURCES.md) (OSV.dev, NVD, Maven Central, npm, Packagist,
53
+ PyPI, NuGet, RubyGems, proxy.golang.org, endoflife.date, FIRST.org EPSS, CISA KEV, deps.dev,
54
+ CIRCL hashlookup) — plus **file checksums** for committed native binaries. It never uploads
55
+ source code, file paths or manifest contents. If you consider your dependency list itself
56
+ sensitive, use the anonymized air-gapped workflow described in the README.
57
+
58
+ Configuration, including your NVD API key, lives in `~/.fad-checker/config.json` with mode
59
+ `0600`. Registry credentials given via `--add-repo --auth`/`--token` are stored there too —
60
+ they are sent only to the registry they belong to, and never appear in a report or export.
package/fad-checker.js CHANGED
@@ -250,6 +250,7 @@ program
250
250
  .option("--no-all-libs", "skip Maven Central queries (outdated check + missing-on-central check)")
251
251
  .option("--no-osv", "skip OSV.dev (Google/GitHub aggregated Maven CVE feed)")
252
252
  .option("--no-nvd", "skip NIST NVD enrichment of matched CVEs")
253
+ .option("--nvd-cpe-match", "ALSO match deps against NVD CPE version ranges (opt-in, LOW PRECISION: ~12% of the findings it adds were corroborated by another scanner — triage aid, not a default)")
253
254
  .option("--no-epss", "skip EPSS (FIRST.org exploit-prediction) enrichment")
254
255
  .option("--no-kev", "skip CISA KEV (known-exploited) enrichment")
255
256
  // Output family: each --report-<type> takes an OPTIONAL path (omit → default name
@@ -1090,6 +1091,30 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1090
1091
  try {
1091
1092
  const { enrichMatches } = require("./lib/nvd");
1092
1093
  await enrichMatches(cveMatches, { verbose, offline, onProgress: (p, t) => st.tick(p, t) });
1094
+
1095
+ // 4c-bis. NVD CPE ranges as an ADDITIVE tier, curated coordinates only.
1096
+ // OSV/GHSA declare affected ranges per release BRANCH; NVD declares them for
1097
+ // every affected branch. For CVE-2020-9546, OSV covers 2.9.0–2.9.10.4 while
1098
+ // NVD also covers 2.0.0–2.7.9.7, so jackson-databind 2.5.2 is affected and
1099
+ // never got a fix. The data was already in the NVD cache and only ever used
1100
+ // to FILTER. Bounded on purpose: `data/cpe-coord-map.json` coordinates only
1101
+ // (no name heuristics — that is what makes CPE-driven scanners noisy), and
1102
+ // only CVEs already enriched, so this adds no network path of its own.
1103
+ let nvdAdded = 0;
1104
+ if (options.nvdCpeMatch) try {
1105
+ const { matchDepsAgainstNvdCpe } = require("./lib/cpe");
1106
+ const records = {};
1107
+ for (const m of cveMatches) {
1108
+ const id = m.cve?.id;
1109
+ if (id && m.cve.configurations?.length && !records[id]) records[id] = m.cve;
1110
+ }
1111
+ const extra = matchDepsAgainstNvdCpe(resolved, records);
1112
+ if (extra.length) {
1113
+ const before = cveMatches.length;
1114
+ cveMatches = mergeBySource(cveMatches, extra);
1115
+ nvdAdded = cveMatches.length - before;
1116
+ }
1117
+ } catch (err) { ui.warn(`NVD CPE matching skipped: ${err.message}`); }
1093
1118
  // 4d. CPE refinement — use NVD's CPE configurations to upgrade match
1094
1119
  // confidence and flag likely false positives (version outside CPE range).
1095
1120
  let filtered = 0;
@@ -1099,7 +1124,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1099
1124
  filtered = cveMatches.filter(m => m.cpeFiltered).length;
1100
1125
  } catch (err) { ui.warn(`CPE refinement skipped: ${err.message}`); }
1101
1126
  const uniqueCves = new Set(cveMatches.map(m => m.cve?.id)).size;
1102
- st.done(`${uniqueCves} CVE${filtered ? ` · ${filtered} false-positive(s) filtered` : ""}${hasNvdKey ? "" : " · no key (slow)"}`);
1127
+ st.done(`${uniqueCves} CVE${nvdAdded ? ` · +${nvdAdded} via CPE ranges` : ""}${filtered ? ` · ${filtered} false-positive(s) filtered` : ""}${hasNvdKey ? "" : " · no key (slow)"}`);
1103
1128
  } catch (err) { st.fail(err.message); }
1104
1129
  }
1105
1130
  }
@@ -53,27 +53,61 @@ function attributeMatchOrigins(matches) {
53
53
  if (!dep || !dep.version) continue;
54
54
  const ver = String(dep.version);
55
55
 
56
+ // A DECLARED version always wins over a transitive provenance for the same version.
57
+ // The overlay may legitimately also reach it as a transitive of some other module, but
58
+ // a manifest that writes `<version>` for a coord is the authority on that version.
59
+ // Without this precedence, xstream:1.4.10 — declared outright in
60
+ // dubbo-registry-eureka — was re-stamped from a test-scoped transitive provenance in
61
+ // dubbo-config-api and demoted out of production.
62
+ const declaredHere = dep.versionPaths && dep.versionPaths[ver];
63
+ const isDeclared = Array.isArray(declaredHere) && declaredHere.length > 0;
64
+
56
65
  // 1. Recovered by the per-module overlay → a transitive of ONE module.
57
- const masked = dep.scope !== "transitive" && Array.isArray(dep.maskedVersions)
58
- ? dep.maskedVersions.find(x => String(x.version) === ver)
59
- : null;
60
- if (masked) {
66
+ // ALL entries for this version, not the first: the same version is routinely masked in
67
+ // several modules, and they can disagree on scope. On Dubbo, jackson-databind:2.10.4 is
68
+ // test-scoped in dubbo-config-spring but COMPILE-scoped in dubbo-configcenter-nacos —
69
+ // taking whichever entry happened to be recorded first would call it dev and drop a
70
+ // genuine production finding out of the count and out of --fail-on.
71
+ const maskedAll = !isDeclared && Array.isArray(dep.maskedVersions)
72
+ ? dep.maskedVersions.filter(x => String(x.version) === ver)
73
+ : [];
74
+ if (maskedAll.length) {
75
+ // Dev only when EVERY module resolving this version does so at test scope. Entries
76
+ // written before the overlay recorded scope have none — treat those as unknown and
77
+ // leave the coord-wide flag alone rather than guessing.
78
+ const scoped = maskedAll.filter(x => x.scope);
79
+ const isDev = scoped.length === maskedAll.length && scoped.every(x => x.scope === "test");
80
+ // Show the production path when there is one: it is the chain an auditor must act on.
81
+ const masked = maskedAll.find(x => x.scope && x.scope !== "test") || maskedAll[0];
82
+ const modules = [...new Set(maskedAll.map(x => x.module).filter(Boolean))];
61
83
  m.dep = {
62
- ...withPaths(dep, masked.module ? [masked.module] : []),
84
+ ...withPaths(dep, modules),
63
85
  scope: "transitive",
64
86
  via: masked.via || [],
65
87
  viaPaths: masked.viaPaths || (masked.via ? [masked.via] : []),
66
88
  depth: masked.depth,
89
+ ...(scoped.length ? { isDev } : {}),
67
90
  };
68
91
  fixed++;
69
92
  continue;
70
93
  }
71
94
 
72
- // 2. Declared → narrow to the manifest(s) that declare THIS version.
95
+ // 2. Declared → narrow to the manifest(s) that declare THIS version, and take the
96
+ // scope from those declarations. `isDev` on the record is coord-wide, so a version
97
+ // declared only at test scope would otherwise inherit the coordinate's production
98
+ // flag. Dev only when EVERY declaration of this version is test/provided — the same
99
+ // widest-wins rule the masked branch uses.
73
100
  const declared = dep.versionPaths && dep.versionPaths[ver];
74
- if (Array.isArray(declared) && declared.length && !sameList(declared, dep.manifestPaths)) {
75
- m.dep = withPaths(dep, declared);
76
- fixed++;
101
+ if (Array.isArray(declared) && declared.length) {
102
+ const scopes = dep.versionScopes && dep.versionScopes[ver];
103
+ const isDev = Array.isArray(scopes) && scopes.length
104
+ ? scopes.every(s => s === "test" || s === "provided")
105
+ : undefined;
106
+ const narrow = !sameList(declared, dep.manifestPaths);
107
+ if (narrow || (isDev !== undefined && isDev !== !!dep.isDev)) {
108
+ m.dep = { ...withPaths(dep, declared), ...(isDev !== undefined ? { isDev } : {}) };
109
+ fixed++;
110
+ }
77
111
  }
78
112
  }
79
113
  return fixed;
@@ -91,10 +91,20 @@ function parseGoSum(text) {
91
91
  for (const raw of String(text || "").split(/\r?\n/)) {
92
92
  const m = raw.trim().match(/^(\S+)\s+(v\S+?)(\/go\.mod)?\s+h1:/);
93
93
  if (!m) continue;
94
+ // go.sum records two kinds of line per module:
95
+ // mod ver h1:… the module ZIP was downloaded → it IS in the build
96
+ // mod ver/go.mod h1:… only its go.mod was read, during minimal version selection
97
+ // A module carrying only the second kind was weighed and rejected: it sits on no
98
+ // classpath, and scanning it invents a dependency. Measured on prometheus v2.30.0
99
+ // (go 1.14, so go.mod lists direct deps only and go.sum is our transitive source):
100
+ // 187 modules have a zip hash, 391 have a go.mod hash alone — and taking all of
101
+ // them put gin, echo AND go-restful in one report, three mutually exclusive web
102
+ // frameworks. 70% of that project's Go production findings came from modules that
103
+ // were never built.
104
+ if (m[3]) continue;
94
105
  const name = m[1];
95
106
  const ver = stripV(m[2]);
96
- // go.sum lists every version in the module graph; keep the highest (the
97
- // comment promised this but the code kept the first one encountered).
107
+ // Several versions can genuinely be downloaded in one graph; keep the highest.
98
108
  const prev = byMod.get(name);
99
109
  if (!prev || compareMavenVersions(ver, prev) > 0) byMod.set(name, ver);
100
110
  }
package/lib/core.js CHANGED
@@ -19,6 +19,47 @@ const SKIP_DIRS = new Set([
19
19
 
20
20
  const coord = v => (v == null ? null : String(v).trim() || null);
21
21
 
22
+ /**
23
+ * Interpolate `${…}` in a single string against a POM property map. Values arrive from
24
+ * xml2js as arrays, hence the unwrap. Resolution is recursive (`${a}` → `${b}` → value),
25
+ * bounded, and stops when it stops making progress; an unknown reference is left verbatim
26
+ * so it still surfaces downstream as an unresolved version rather than a wrong one.
27
+ *
28
+ * Deliberately local to core.js: the equivalent in cve-match.js can't be reused without
29
+ * making core depend on it, and cve-match already depends on core.
30
+ */
31
+ function interpolate(value, props) {
32
+ if (value == null) return value;
33
+ let out = String(value);
34
+ for (let i = 0; i < 10 && out.includes("${"); i++) {
35
+ const next = out.replace(/\$\{\s*([\w._-]+)\s*\}/g, (m, k) => {
36
+ const val = props?.[k];
37
+ if (Array.isArray(val)) return val[0];
38
+ return val != null ? val : m;
39
+ });
40
+ if (next === out) break;
41
+ out = next;
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * A dependencyManagement node with its coordinate resolved against `props`. Returns a NEW
48
+ * node — these xml2js structures are shared across POMs, so mutating one corrupts every
49
+ * other reader of the same parsed document.
50
+ */
51
+ function resolveDepNodeProps(dep, props) {
52
+ if (!dep || !props) return dep;
53
+ const out = { ...dep };
54
+ for (const field of ["groupId", "artifactId", "version"]) {
55
+ const raw = dep[field]?.[0];
56
+ if (raw == null) continue;
57
+ const resolved = interpolate(raw, props);
58
+ if (resolved !== raw) out[field] = [resolved];
59
+ }
60
+ return out;
61
+ }
62
+
22
63
  const defaultPomSkip = child => SKIP_DIRS.has(path.basename(child));
23
64
 
24
65
  function findPomFiles(dir, skipDir = defaultPomSkip) {
@@ -224,9 +265,24 @@ async function getAllInheritedProps(pomPath, allPomMetadata, cache) {
224
265
 
225
266
  for (const pom of toImport) {
226
267
  const imported = await getAllInheritedProps(pom.pomPath, allPomMetadata, cache);
227
- merged.properties = { ...merged.properties, ...imported.properties };
268
+ // `<scope>import</scope>` imports a BOM's <dependencyManagement> and NOTHING else.
269
+ // The BOM resolves its managed versions in ITS OWN property context, then those
270
+ // resolved versions are what the importer receives — its <properties> never become
271
+ // the importer's. Merging them (and merging them so the BOM WINS, as this line used
272
+ // to) lets a BOM silently redefine the importing project's own property values.
273
+ //
274
+ // Real case, Apache Dubbo 2.7.8: the reactor root sets
275
+ // <hibernate_validator_version>5.2.4.Final</…>, dubbo-dependencies-bom redefines it
276
+ // to 5.4.1.Final, and dubbo-filter-validation's <version>${hibernate_validator_version}</version>
277
+ // resolved to the wrong one — a different version means a different CVE set.
278
+ // `mvn dependency:tree` reports 5.2.4.Final for that module.
279
+ //
280
+ // So: interpolate the BOM's managed entries against the BOM's properties here, at
281
+ // the import boundary, and drop the properties. (lib/transitive.js#effectivePom
282
+ // already does the equivalent for EXTERNAL import BOMs.)
228
283
  merged.dependencies.push(...imported.dependencies);
229
- merged.dependencyManagement.push(...imported.dependencyManagement);
284
+ merged.dependencyManagement.push(
285
+ ...imported.dependencyManagement.map(d => resolveDepNodeProps(d, imported.properties)));
230
286
  }
231
287
 
232
288
  // Cache the (still-mutating) object BEFORE recursing into the parent so a
package/lib/cpe.js CHANGED
@@ -127,21 +127,21 @@ function matchVersionRange(depVersion, cpeMatch) {
127
127
  *
128
128
  * Returns true if any vulnerable cpeMatch in the node matches the dep.
129
129
  */
130
- function nodeAffectsDep(node, dep, cpeCoordMap) {
130
+ function nodeAffectsDep(node, dep, cpeCoordMap, opts = {}) {
131
131
  if (!node) return false;
132
132
  const matches = node.cpeMatch || node.cpe_match || [];
133
133
  for (const m of matches) {
134
134
  if (m.vulnerable === false) continue;
135
135
  const parsed = parseCpe23Cached(m);
136
136
  if (!parsed) continue;
137
- if (!cpeMatchesDep(parsed, dep, cpeCoordMap)) continue;
137
+ if (!cpeMatchesDep(parsed, dep, cpeCoordMap, opts)) continue;
138
138
  if (!matchVersionRange(dep.version, m)) continue;
139
139
  return true;
140
140
  }
141
141
  // Recurse into children (NVD nests AND/OR nodes)
142
142
  if (Array.isArray(node.children)) {
143
143
  for (const child of node.children) {
144
- if (nodeAffectsDep(child, dep, cpeCoordMap)) return true;
144
+ if (nodeAffectsDep(child, dep, cpeCoordMap, opts)) return true;
145
145
  }
146
146
  }
147
147
  return false;
@@ -198,7 +198,7 @@ function nodeIsContextOnly(node) {
198
198
  * equals/contains product. Same logic as cve-match.vendorMatchesGroup
199
199
  * but kept local here so cpe.js is standalone.
200
200
  */
201
- function cpeMatchesDep(cpe, dep, cpeCoordMap) {
201
+ function cpeMatchesDep(cpe, dep, cpeCoordMap, opts = {}) {
202
202
  if (!cpe || !dep) return false;
203
203
  if (cpe.part !== "a" && cpe.part !== "*") return false; // we only care about apps
204
204
  const map = cpeCoordMap || loadCpeCoordMap();
@@ -214,6 +214,12 @@ function cpeMatchesDep(cpe, dep, cpeCoordMap) {
214
214
  if (inList(map.byVendorProduct?.[vp])) return true;
215
215
  if (inList(map.byProduct?.[cpe.product?.toLowerCase()])) return true;
216
216
 
217
+ // `curatedOnly` stops here. The heuristic below is safe when REFINING a match another
218
+ // source already made (worst case it declines to upgrade confidence), but it is not safe
219
+ // as a way to CREATE matches: name-similarity between a Maven coordinate and a CPE
220
+ // product is exactly what makes CPE-driven scanners noisy. matchDepsAgainstNvdCpe sets it.
221
+ if (opts.curatedOnly) return false;
222
+
217
223
  // Heuristic fallback
218
224
  if (dep.ecosystem === "npm") {
219
225
  const name = (dep.artifactId || "").toLowerCase();
@@ -252,7 +258,7 @@ function altDepKey(dep) {
252
258
  * - "exact" when a curated mapping confirms vendor:product → dep
253
259
  * - "probable" when only heuristic matched
254
260
  */
255
- function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
261
+ function evaluateCveForDep(cveRecord, dep, cpeCoordMap, opts = {}) {
256
262
  const map = cpeCoordMap || loadCpeCoordMap();
257
263
  const configs = cveRecord?.configurations || [];
258
264
  let bestConfidence = null;
@@ -272,9 +278,9 @@ function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
272
278
  // this, a "vulnerable:false" platform node makes .every() false and the real
273
279
  // finding is wrongly dropped as a CPE false-positive.
274
280
  const vulnNodes = nodes.filter(n => !nodeIsContextOnly(n));
275
- passes = vulnNodes.length > 0 && vulnNodes.every(n => nodeAffectsDep(n, dep, map));
281
+ passes = vulnNodes.length > 0 && vulnNodes.every(n => nodeAffectsDep(n, dep, map, opts));
276
282
  } else {
277
- passes = nodes.some(n => nodeAffectsDep(n, dep, map));
283
+ passes = nodes.some(n => nodeAffectsDep(n, dep, map, opts));
278
284
  }
279
285
  if (passes) {
280
286
  // Determine confidence: scan vulnerable cpeMatches that hit
@@ -283,7 +289,7 @@ function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
283
289
  if (m.vulnerable === false) continue;
284
290
  const parsed = parseCpe23Cached(m);
285
291
  if (!parsed) continue;
286
- if (!cpeMatchesDep(parsed, dep, map)) continue;
292
+ if (!cpeMatchesDep(parsed, dep, map, opts)) continue;
287
293
  if (!matchVersionRange(dep.version, m)) continue;
288
294
  const vp = `${parsed.vendor}:${parsed.product}`.toLowerCase();
289
295
  const depKey = depToKey(dep).toLowerCase();
@@ -302,7 +308,7 @@ function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
302
308
  for (const uri of cveRecord.cpes) {
303
309
  const parsed = parseCpe23(uri);
304
310
  if (!parsed) continue;
305
- if (cpeMatchesDep(parsed, dep, map)) {
311
+ if (cpeMatchesDep(parsed, dep, map, opts)) {
306
312
  note("probable");
307
313
  break;
308
314
  }
@@ -355,10 +361,96 @@ function refineMatchesWithCpe(matches, opts = {}) {
355
361
  return matches;
356
362
  }
357
363
 
364
+ /**
365
+ * ADDITIVE matching tier: NVD's CPE version ranges, for CURATED coordinates only.
366
+ *
367
+ * OSV/GHSA declare affected ranges per release *branch* — for CVE-2020-9546 only
368
+ * 2.9.0–2.9.10.4, the branch that got a fix. NVD declares three ranges for the same CVE
369
+ * (`2.0.0 ≤ v < 2.7.9.7`, `2.8.0 ≤ v < 2.8.11.6`, `2.9.0 ≤ v < 2.9.10.4`), so
370
+ * jackson-databind 2.5.2 is affected and simply never received a fix. fad already had that
371
+ * data — every enriched CVE's `configurations` sits in the NVD cache — but only ever used it
372
+ * SUBTRACTIVELY, to filter false positives. This uses it additively.
373
+ *
374
+ * MEASURED PRECISION IS POOR, which is why the orchestrator keeps this behind an opt-in flag
375
+ * (`--nvd-cpe-match`). On Dubbo 2.7.8 it adds 76 findings of which only 9 (12%) are
376
+ * corroborated by Snyk. The cause is structural and not fixable by better curation: CPE
377
+ * products are FRAMEWORK-level (`spring_framework`, `netty`, `log4j`) while Maven coordinates
378
+ * are ARTIFACT-level, so a framework CVE lands on every artifact of that framework —
379
+ * CVE-2016-1000027 is a spring-web flaw and CPE puts it on spring-core. This is the same
380
+ * limitation that makes CPE-driven scanners noisy. Useful as a triage aid ("what might I be
381
+ * missing?"), not as a default.
382
+ *
383
+ * Strictly bounded, because the cost of getting this wrong is a false-positive engine:
384
+ * - `curatedOnly`: a coordinate must appear in `data/cpe-coord-map.json`. No name heuristics.
385
+ * - Only CVEs already in the NVD cache are considered — no new fetch, no new network path.
386
+ * - Every distinct concrete version is evaluated; unresolved or `${…}` versions never match,
387
+ * mirroring the rule in cve-match.js that an unversioned dep is not assumed vulnerable.
388
+ *
389
+ * @param resolvedDeps Map<coordKey, depRecord>
390
+ * @param nvdRecordsById {[cveId]: nvdRecord} records with `configurations`
391
+ * @returns match[] in the shape mergeBySource expects, tagged `source: "nvd"`
392
+ */
393
+ function matchDepsAgainstNvdCpe(resolvedDeps, nvdRecordsById, opts = {}) {
394
+ const map = opts.cpeCoordMap || loadCpeCoordMap();
395
+ const out = [];
396
+ if (!resolvedDeps || !nvdRecordsById) return out;
397
+
398
+ // UNAMBIGUOUS entries only: a CPE product that maps to a SINGLE Maven coordinate.
399
+ // The curated map deliberately maps one CPE to several artifacts — its own comment says
400
+ // so ("one CPE may legitimately map to several Maven artifacts"). That is right for
401
+ // FILTERING, where any member matching means "don't call this a false positive", and
402
+ // catastrophic for MATCHING, where it would assert the CVE affects all of them.
403
+ // Measured on Dubbo: allowing 1:N entries produced 262 new findings of which only 8%
404
+ // were corroborated by any other scanner — CVE-2016-1000027 (a spring-web flaw) landed
405
+ // on spring-core/-beans/-context, CVE-2019-20444 (netty-codec-http) on
406
+ // netty-common/-codec/-handler. That is precisely the CPE noise this tier must not add.
407
+ const curated = new Set();
408
+ for (const list of [...Object.values(map.byVendorProduct || {}), ...Object.values(map.byProduct || {})]) {
409
+ if (!Array.isArray(list) || list.length !== 1) continue;
410
+ curated.add(String(list[0]).toLowerCase());
411
+ }
412
+ const candidates = [];
413
+ for (const dep of resolvedDeps.values()) {
414
+ if (!dep || dep.ecosystem !== "maven") continue;
415
+ if (dep.provenance === "binary") continue; // no coordinate, identified by hash
416
+ const key = `${dep.groupId}:${dep.artifactId}`.toLowerCase();
417
+ if (!curated.has(key)) continue;
418
+ const versions = (Array.isArray(dep.versions) && dep.versions.length ? dep.versions : [dep.version])
419
+ .filter(v => v && !/\$\{/.test(String(v)));
420
+ if (versions.length) candidates.push([dep, versions]);
421
+ }
422
+ if (!candidates.length) return out;
423
+
424
+ for (const [cveId, record] of Object.entries(nvdRecordsById)) {
425
+ if (!record?.configurations?.length) continue;
426
+ for (const [dep, versions] of candidates) {
427
+ for (const version of versions) {
428
+ const probe = { ...dep, version };
429
+ if (!evaluateCveForDep(record, probe, map, { curatedOnly: true }).affected) continue;
430
+ out.push({
431
+ dep: probe,
432
+ cve: {
433
+ id: cveId,
434
+ severity: record.severity || "UNKNOWN",
435
+ score: record.score ?? null,
436
+ description: record.description || "",
437
+ cvssVector: record.cvssVector || null,
438
+ published: record.published || null,
439
+ },
440
+ source: "nvd",
441
+ confidence: "exact", // curated coordinate + explicit NVD version range
442
+ });
443
+ }
444
+ }
445
+ }
446
+ return out;
447
+ }
448
+
358
449
  module.exports = {
359
450
  parseCpe23,
360
451
  matchVersionRange,
361
452
  cpeMatchesDep,
453
+ matchDepsAgainstNvdCpe,
362
454
  nodeAffectsDep,
363
455
  evaluateCveForDep,
364
456
  cveCpeNamesDep,
package/lib/cve-match.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * @email: pp9ping@gmail.com
7
7
  */
8
8
  const { coord } = require("./core");
9
- const { compareMavenVersions, isVersionAffected } = require("./maven-version");
9
+ const { compareMavenVersions, isVersionAffected, normalizeHardPin } = require("./maven-version");
10
10
  const { resolveTransitiveDeps } = require("./transitive");
11
11
  const { makeDepRecord } = require("./dep-record");
12
12
 
@@ -29,7 +29,13 @@ function resolveDepVersion(rawVersion, allProps) {
29
29
  if (next === v) break; // no progress → remaining refs are unresolvable
30
30
  v = next;
31
31
  }
32
- return v;
32
+ // Maven hard pin: "[1.2.3]" means EXACTLY 1.2.3. It is a concrete version wearing range
33
+ // brackets, so unwrap it — keeping the brackets corrupts the coordinate for every consumer
34
+ // downstream (report, purl, SBOM/CSAF/SARIF/JSON) and makes the finding un-joinable with
35
+ // any other tool's output. A comma means a genuine range ("[1.0,2.0)", "(,1.5]") and is
36
+ // left verbatim: picking a version out of it is resolution, not interpolation, and it
37
+ // still surfaces as unresolved. Runs AFTER interpolation so "[${netty.version}]" works.
38
+ return normalizeHardPin(v);
33
39
  }
34
40
 
35
41
  /**
@@ -92,6 +98,14 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
92
98
  if (!rec.versionPaths) rec.versionPaths = {};
93
99
  const paths = rec.versionPaths[concrete] || (rec.versionPaths[concrete] = []);
94
100
  if (!paths.includes(pomPath)) paths.push(pomPath);
101
+ // …and WHICH SCOPE that declaration uses. `isDev` on the record is coord-wide
102
+ // (false as soon as any occurrence is production), so without this a version
103
+ // declared only at test scope inherits the coordinate's production flag and
104
+ // inflates both the production count and the CI gate. Mirror of
105
+ // maskedVersions[].scope for overlay-recovered versions.
106
+ if (!rec.versionScopes) rec.versionScopes = {};
107
+ const scopes = rec.versionScopes[concrete] || (rec.versionScopes[concrete] = []);
108
+ if (!scopes.includes(scope)) scopes.push(scope);
95
109
  }
96
110
  }
97
111
  };
@@ -174,8 +188,20 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
174
188
  const rootEcoType = new Map();
175
189
  for (const d of directs) rootEcoType.set(`${d.groupId}:${d.artifactId}`, d.ecosystemType || d.ecosystem || "maven");
176
190
 
191
+ // Maven's scope matrix: `test → compile = test`. The compile dependencies of a
192
+ // test-scoped dep ARE on the test classpath, recursively — only `test → test` is
193
+ // omitted (SCOPE_MATRIX handles that). So when test roots are in the scan set, "test"
194
+ // has to be an accepted propagated scope too, otherwise every child of a test root is
195
+ // discarded at the first hop and the dev chapter only ever shows DIRECTLY declared test
196
+ // deps. Measured on Apache Dubbo 2.7.8: this is what hid spring-boot:1.5.17.RELEASE,
197
+ // which `mvn dependency:tree` reports at scope=test via
198
+ // registry-test → registry-server-integration → spring-boot-starter.
199
+ const includedScopes = opts.includedScopes
200
+ || (opts.includeTestDeps ? ["compile", "runtime", "provided", "test"] : undefined);
201
+
177
202
  const transitives = await resolveTransitiveDeps(directs, {
178
203
  ...opts,
204
+ ...(includedScopes ? { includedScopes } : {}),
179
205
  rootDepMgmt,
180
206
  });
181
207
 
@@ -188,6 +214,11 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
188
214
  version: t.version,
189
215
  versions: t.version && !/\$\{/.test(t.version) ? [t.version] : [],
190
216
  scope: "transitive",
217
+ // A coord reachable ONLY through a test-scoped root lives on the test classpath.
218
+ // It must land in the dev chapter and stay out of the production count and the
219
+ // CI gate — reporting a test-only dependency as a production finding is a false
220
+ // positive that costs an auditor real time.
221
+ isDev: t.scope === "test",
191
222
  pomPaths: [],
192
223
  via: t.via,
193
224
  viaPaths: t.viaPaths || [t.via],
@@ -164,10 +164,31 @@ function parseRange(rangeStr) {
164
164
  };
165
165
  }
166
166
 
167
+ /**
168
+ * Maven's hard-pin syntax: "[1.2.3]" means EXACTLY 1.2.3. It is a concrete version wearing
169
+ * range brackets, and upstream POMs on Maven Central do use it (e.g. netty declares
170
+ * "[4.1.35.Final]"). Unwrap it to the bare version.
171
+ *
172
+ * Keeping the brackets corrupts the coordinate for every consumer downstream — the report,
173
+ * the purl, and the SBOM/CSAF/SARIF/JSON exports — and makes the finding un-joinable with any
174
+ * other tool's output for the same dependency.
175
+ *
176
+ * A comma means a genuine range ("[1.0,2.0)", "(,1.5]") and is returned unchanged: choosing a
177
+ * version out of a range is resolution, not normalisation, and an unresolved range should keep
178
+ * surfacing as unresolved rather than silently becoming a concrete version.
179
+ */
180
+ function normalizeHardPin(versionStr) {
181
+ if (versionStr == null) return versionStr;
182
+ const s = String(versionStr).trim();
183
+ const pin = /^\[\s*([^,\[\]]+?)\s*\]$/.exec(s);
184
+ return pin ? pin[1] : versionStr;
185
+ }
186
+
167
187
  module.exports = {
168
188
  parseMavenVersion,
169
189
  compareMavenVersions,
170
190
  isVersionAffected,
171
191
  versionLikeBound,
172
192
  parseRange,
193
+ normalizeHardPin,
173
194
  };
package/lib/retire.js CHANGED
@@ -173,18 +173,33 @@ const DEFAULT_RETIRE_SKIP_DIRS = [
173
173
  *
174
174
  * retire walks with POSIX-style paths (its own matchers use `/`), so we emit `/`.
175
175
  */
176
- function buildRetireIgnorePatterns({ srcDir, excludePath = [], defaultExcludes = true } = {}) {
176
+ function buildRetireIgnorePatterns({ srcDir, excludePath = [], defaultExcludes = true, sep = path.sep } = {}) {
177
177
  const lines = [];
178
+ // retire compiles each line into a REGEX and tests it against the file path AND
179
+ // path.resolve(file) — both of which use BACKSLASHES on Windows. A forward-slash
180
+ // pattern therefore never matches there, and every exclusion (including the defaults
181
+ // that keep retire out of node_modules) is silently inert. A character class like
182
+ // [\\/] is not an option: retire escapes `[` and `]` before compiling. So emit the
183
+ // POSIX form plus, on Windows, the native-separator twin — retire's `.some()` means
184
+ // either one matching is enough, and on POSIX the twin is identical and deduped away.
185
+ const push = line => { if (!lines.includes(line)) lines.push(line); };
186
+ const emit = body => {
187
+ push(`@${body}`);
188
+ if (sep === "\\") push(`@${body.split("/").join("\\")}`);
189
+ };
190
+
178
191
  if (defaultExcludes !== false) {
179
- for (const d of DEFAULT_RETIRE_SKIP_DIRS) lines.push(`@/${d}/`);
192
+ for (const d of DEFAULT_RETIRE_SKIP_DIRS) emit(`/${d}/`);
180
193
  }
181
- const root = srcDir ? path.resolve(srcDir).split(path.sep).join("/").replace(/\/+$/, "") : "";
194
+ // Separator-agnostic on INPUT (path.resolve yields backslashes on Windows); `sep`
195
+ // only decides what we emit.
196
+ const root = srcDir ? path.resolve(srcDir).replace(/\\/g, "/").replace(/\/+$/, "") : "";
182
197
  for (const g of excludePath || []) {
183
198
  // Normalise like compileGlobs: strip a leading ./ or /, a trailing /, and a
184
199
  // trailing /** (the dir's whole subtree is already covered by the segment).
185
200
  const base = String(g || "").trim().replace(/^\.?\/+/, "").replace(/\/+$/, "").replace(/\/\*\*$/, "");
186
201
  if (!base) continue;
187
- lines.push(root ? `@${root}/${base}/` : `@/${base}/`);
202
+ emit(root ? `${root}/${base}/` : `/${base}/`);
188
203
  }
189
204
  return lines;
190
205
  }
package/lib/transitive.js CHANGED
@@ -26,6 +26,7 @@ const fs = require("fs");
26
26
  const path = require("path");
27
27
  const os = require("os");
28
28
  const { parseStringPromise } = require("xml2js");
29
+ const { normalizeHardPin } = require("./maven-version");
29
30
 
30
31
  const POM_CACHE_DIR = path.join(os.homedir(), ".fad-checker", "poms-cache");
31
32
  const MAVEN_CENTRAL = "https://repo1.maven.org/maven2";
@@ -140,7 +141,7 @@ async function parsePomXml(xml) {
140
141
  return depsBlock.dependency.map(d => ({
141
142
  groupId: coord(d.groupId?.[0]),
142
143
  artifactId: coord(d.artifactId?.[0]),
143
- version: coord(d.version?.[0]),
144
+ version: normalizeHardPin(coord(d.version?.[0])),
144
145
  scope: coord(d.scope?.[0]) || "compile",
145
146
  optional: d.optional?.[0] === "true",
146
147
  type: coord(d.type?.[0]) || "jar",
@@ -242,7 +243,7 @@ async function effectivePom(g, a, v, opts = {}, seen = new Set()) {
242
243
  ...d,
243
244
  groupId: resolveProps(d.groupId, effProps, builtins),
244
245
  artifactId: resolveProps(d.artifactId, effProps, builtins),
245
- version: resolveProps(d.version, effProps, builtins),
246
+ version: normalizeHardPin(resolveProps(d.version, effProps, builtins)),
246
247
  });
247
248
  merged.depMgmt = merged.depMgmt.map(resolveDep);
248
249
  merged.deps = merged.deps.map(resolveDep);
@@ -392,6 +393,25 @@ async function resolveTransitiveDeps(directDeps, opts = {}) {
392
393
  if (!existing.viaPaths.some(p => p.join("→") === sig)) {
393
394
  existing.viaPaths.push(via);
394
395
  }
396
+ // WIDEST scope wins across paths, and the version follows the scope.
397
+ // We dedupe by `g:a` and keep the first chain walked, but scope is a
398
+ // property of the coord, not of the chain: a coord reachable from both a
399
+ // test root and a compile root really is on the compile classpath.
400
+ // Without this, BFS order decides two things it has no business
401
+ // deciding. (1) The scope: a production dependency reached through a
402
+ // test path first gets stamped "test" and drops out of the production
403
+ // count and the --fail-on gate. (2) The VERSION: the test path's version
404
+ // would be reported as the production one, so a test-only version gets
405
+ // scanned as production while the version actually on the compile
406
+ // classpath is never scanned at all. So on widening we adopt the wider
407
+ // path's version and chain too. Never narrow, only widen; the version
408
+ // the test path holds is recovered per-module by lib/version-overlay.js.
409
+ if (existing.scope === "test" && propagated !== "test") {
410
+ existing.scope = propagated;
411
+ existing.version = resolvedVersion;
412
+ existing.via = via;
413
+ existing.depth = node.depth + 1;
414
+ }
395
415
  }
396
416
  continue;
397
417
  }
@@ -194,7 +194,17 @@ async function expandPerModuleOverlay(resolvedDeps, store, propsByPom, opts = {}
194
194
  ...tOpts,
195
195
  rootDepMgmt: moduleMgmt,
196
196
  maxDepth: opts.maxDepth || 6,
197
- includedScopes: ["compile", "runtime", "provided"],
197
+ // Maven: `test → compile = test`. A version reachable only through a
198
+ // test-scoped dep is on that module's TEST classpath, and the global pass
199
+ // masks it exactly like any other per-module version. Recovering it is the
200
+ // whole point of this overlay, so "test" has to be an accepted propagated
201
+ // scope whenever test deps are in scope at all. Measured on Apache Dubbo
202
+ // 2.7.8: this single omission accounted for ALL 78 findings OSV-Scanner
203
+ // reported that fad missed (jackson-databind:2.8.4:test in registry-sofa,
204
+ // hibernate-validator:5.2.4.Final:test in filter-validation, …).
205
+ includedScopes: opts.includeTestDeps
206
+ ? ["compile", "runtime", "provided", "test"]
207
+ : ["compile", "runtime", "provided"],
198
208
  });
199
209
  } catch { continue; }
200
210
 
@@ -205,11 +215,25 @@ async function expandPerModuleOverlay(resolvedDeps, store, propsByPom, opts = {}
205
215
  if (!existing) continue; // additive to coords already scanned
206
216
  if (existing.provenance === "embedded" || existing.provenance === "binary") continue;
207
217
  if (!Array.isArray(existing.versions)) existing.versions = isConcrete(existing.version) ? [existing.version] : [];
208
- if (existing.versions.includes(String(v))) continue; // already scanned this version
209
- existing.versions.push(String(v));
218
+ // Record provenance even when the version is ALREADY in the scan set. Several
219
+ // modules routinely resolve the same version, and they can disagree on scope —
220
+ // on Dubbo, jackson-databind:2.10.4 arrives test-scoped in dubbo-config-spring
221
+ // and COMPILE-scoped in dubbo-configcenter-nacos. Bailing out on the first
222
+ // module to reach a version left only that module's entry, so
223
+ // lib/attribution.js saw a single test-scoped provenance and demoted a genuine
224
+ // production finding to dev. Only the `versions[]` push is conditional.
225
+ const alreadyScanned = existing.versions.includes(String(v));
226
+ if (!alreadyScanned) existing.versions.push(String(v));
210
227
  existing.maskedVersions = existing.maskedVersions || [];
211
- existing.maskedVersions.push({ version: String(v), via: t.via, viaPaths: t.viaPaths, module: pomPath, depth: t.depth });
212
- recovered.push({ coord: key, version: String(v), module: pomPath, had: existing.version });
228
+ if (existing.maskedVersions.some(x => String(x.version) === String(v) && x.module === pomPath)) continue;
229
+ // `scope` travels with the recovered version: the record is coord-wide, so this
230
+ // is the only thing that tells lib/attribution.js whether THIS version sits on a
231
+ // test classpath. Without it a test-only version inherits the coord's production
232
+ // flag and inflates the production count.
233
+ existing.maskedVersions.push({ version: String(v), via: t.via, viaPaths: t.viaPaths, module: pomPath, depth: t.depth, scope: t.scope });
234
+ // `recovered` reports versions ADDED to the scan set, so a provenance-only entry
235
+ // for a version another module already contributed must not inflate the count.
236
+ if (!alreadyScanned) recovered.push({ coord: key, version: String(v), module: pomPath, had: existing.version });
213
237
  }
214
238
  }
215
239
  return { appended: recovered.length, modules, recovered };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "2.4.4",
3
+ "version": "2.4.6",
4
4
  "description": "Scan ALL Maven, Gradle, 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",