fad-checker 2.4.4 → 2.4.5
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 +74 -0
- package/CHANGELOG.md +171 -0
- package/CONTRIBUTING.md +61 -0
- package/LICENSE +21 -0
- package/README.md +62 -36
- package/SECURITY.md +60 -0
- package/fad-checker.js +26 -1
- package/lib/attribution.js +43 -9
- package/lib/core.js +58 -2
- package/lib/cpe.js +101 -9
- package/lib/cve-match.js +33 -2
- package/lib/maven-version.js +21 -0
- package/lib/retire.js +19 -4
- package/lib/transitive.js +22 -2
- package/lib/version-overlay.js +29 -5
- package/package.json +1 -1
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
|
package/CONTRIBUTING.md
ADDED
|
@@ -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
|
[](https://www.npmjs.com/package/fad-checker)
|
|
4
4
|
[](https://www.npmjs.com/package/fad-checker)
|
|
5
|
-
[](
|
|
5
|
+
[](LICENSE)
|
|
6
6
|
[](https://nodejs.org)
|
|
7
|
+
[](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
|
|
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
|
|
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
|
|
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
|
|
23
|
-
- **Crypto material
|
|
24
|
-
- **No build tools
|
|
25
|
-
- **CVE, merged & prioritised
|
|
26
|
-
- **Per-module Maven version mediation
|
|
27
|
-
- **Air-gapped
|
|
28
|
-
- **Supply-chain risk
|
|
29
|
-
- **Lifecycle
|
|
30
|
-
- **Licenses** *(opt-in `--licenses`)
|
|
31
|
-
- **Audit-grade & reproducible
|
|
32
|
-
- **Outputs & CI
|
|
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 & 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 on **Apache Dubbo 2.7.8** (105 modules) against OSV-Scanner, Snyk, Trivy and Grype+Syft: at **full capability** fad finds 790 of a 908-pair union (87%, the highest of the five, and it still misses 118 that Snyk finds); **under `unshare -rn` with no network interface** it recovers **657/657** of OSV-Scanner's *online* result, versus 45 / 40 / 37 for the others. → [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
|
|
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
|
|
65
|
-
| **1. CVE** *(X direct, Y indirect, Z dev)* | CVEProject + OSV.dev + NVD + CPE | **1.1 Production
|
|
66
|
-
| **2. Unmanaged / unversioned components** | deps.dev + CIRCL (by checksum), retire.js, built-in X.509 | **2.1 Embedded binaries
|
|
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 & 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
92
|
-
hostnames/usernames
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
131
|
-
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
|
|
132
|
-
- [`docs/COMPARISON.md`](docs/COMPARISON.md)
|
|
133
|
-
- [`docs/
|
|
134
|
-
- [`
|
|
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
|
|
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
|
}
|
package/lib/attribution.js
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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,
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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;
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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],
|
package/lib/maven-version.js
CHANGED
|
@@ -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)
|
|
192
|
+
for (const d of DEFAULT_RETIRE_SKIP_DIRS) emit(`/${d}/`);
|
|
180
193
|
}
|
|
181
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/version-overlay.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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.
|
|
212
|
-
|
|
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.
|
|
3
|
+
"version": "2.4.5",
|
|
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",
|