fad-checker 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CRITICAL-REVIEW.md +343 -0
- package/fad-checker.js +6 -2
- package/lib/cpe.js +23 -5
- package/lib/cve-match.js +13 -7
- package/lib/maven-version.js +6 -0
- package/lib/nvd.js +57 -12
- package/lib/osv.js +48 -23
- package/lib/outdated.js +21 -1
- package/lib/transitive.js +12 -4
- package/package.json +1 -1
- package/test/cpe.test.js +18 -0
- package/test/cve-match.test.js +56 -0
- package/test/maven-version.test.js +9 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# fad-checker — Analyse critique complète
|
|
2
|
+
|
|
3
|
+
**Date :** 2026-05-22
|
|
4
|
+
**Périmètre :** intégralité du code (`fad-checker.js` + `lib/**/*.js` + `data/*.json` + `test/`)
|
|
5
|
+
**Méthode :** 4 audits parallèles à effort xhigh (reuse / quality / efficiency / **faux positifs**)
|
|
6
|
+
**Verdict global :** architecture saine, mais **4 chemins fail-open en cascade dans le matcher CVE** produisent un taux de faux positifs estimé entre **20 % et 60 %** selon le niveau de confiance retenu. À traiter en priorité avant toute exploitation en production.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## TL;DR — Risque de faux positifs
|
|
11
|
+
|
|
12
|
+
| Tier de confiance | Taux de FP estimé sur ~500 deps | Recommandation |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `exact` uniquement | 5–10 % | Utilisable (triage léger) |
|
|
15
|
+
| `exact` + `probable` | 20–30 % | Triage manuel obligatoire |
|
|
16
|
+
| Tous tiers (`exact` + `probable` + `possible`) | **40–60 %** | Inexploitable sans tri humain |
|
|
17
|
+
|
|
18
|
+
Quatre fail-open en cascade :
|
|
19
|
+
|
|
20
|
+
1. **H1** — `isVersionAffected` retourne `true` quand le CVE n'a aucune borne de version (`lib/maven-version.js:101-124`).
|
|
21
|
+
2. **H2** — `vendorMatchesGroup` fait du *substring matching* (`g.includes(v) || v.includes(g)`) — `vendor="open"` matche `org.opensaml.*` (`lib/cve-match.js:164-173`).
|
|
22
|
+
3. **H3** — Tier-3 `possible` émis pour toute collision d'`artifactId`, même quand le vendor est totalement étranger (`lib/cve-match.js:191-198`).
|
|
23
|
+
4. **H4** — Le filtre CPE (« primary FP filter ») se contente de **marquer** `cpeFiltered=true`, il ne supprime jamais (`lib/cpe.js:269-272`).
|
|
24
|
+
|
|
25
|
+
Ces quatre interagissent de manière pathologique : H3 laisse passer une masse de matches `possible`, H2 en remonte beaucoup en `probable`, H1 valide toutes les versions, et H4 ne filtre plus rien en aval.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## A. Faux positifs (critère #1 demandé)
|
|
30
|
+
|
|
31
|
+
### HIGH
|
|
32
|
+
|
|
33
|
+
#### H1 — `isVersionAffected` fail-open quand pas de bornes
|
|
34
|
+
**Fichier :** `lib/maven-version.js:101-124`
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
if (spec.version && spec.version !== "0" && spec.version !== "*") { /* lower */ }
|
|
38
|
+
if (spec.lessThan) { ... }
|
|
39
|
+
if (spec.lessThanOrEqual) { ... }
|
|
40
|
+
if (!spec.lessThan && !spec.lessThanOrEqual && spec.version && …) { /* exact */ }
|
|
41
|
+
return true; // ← fall-through "affected"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Empiriquement : `isVersionAffected("2.14", { status: "affected" })` → `true`. CVEProject contient régulièrement des entrées `versions:[{status:"affected"}]` sans bornes (stub mal formé). Combiné à H2, **toute dep qui hit le `byProduct` tier-2 avec un range sparse est flaggée sur toute version**.
|
|
45
|
+
|
|
46
|
+
**FP concret :** CVE listant `affected: [{ product: "log4j", versions: [{ status: "affected" }] }]` → flag `log4j-core 2.99.0` (patché) car pas de borne haute, pas d'exact-match, fall-through → `true`.
|
|
47
|
+
|
|
48
|
+
**Fix :**
|
|
49
|
+
```js
|
|
50
|
+
if (!spec.version && !spec.lessThan && !spec.lessThanOrEqual) return false;
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
#### H2 — `vendorMatchesGroup` substring-match → cross-vendor leak
|
|
56
|
+
**Fichier :** `lib/cve-match.js:164-173`
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
if (g.includes(v) || v.includes(g)) return true;
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`vendorMatchesGroup("spring", "foospringbar")` → `true`. NVD/CVEProject contiennent régulièrement des vendors `"a"`, `"oss"`, `"com"`, `"java"`, `"go"`, `"web"`, `"open"`, `"ibm"`, `"net"` — tous présents en sous-chaîne dans des groupIds réels.
|
|
63
|
+
|
|
64
|
+
**FP concrets :**
|
|
65
|
+
- CVE `vendor="open"` matche `org.opensaml:opensaml-core` (`"opensaml".includes("open")`).
|
|
66
|
+
- CVE `vendor="ibm"` matche `com.ibmcloudant:cloudant-client`.
|
|
67
|
+
- CVE `vendor="spring"` matche `org.springframework.*` ET `com.foospringbar.*`.
|
|
68
|
+
|
|
69
|
+
**Fix :** restreindre à (a) `g === v`, (b) `g.split(".").includes(v)`, (c) `v.includes(g)` seulement si `g.length >= 4`. Drop des branches substring non bornées.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
#### H3 — Tier-3 `possible` émis avec vendor totalement étranger
|
|
74
|
+
**Fichier :** `lib/cve-match.js:191-198`
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
const productMatches = byProduct[dep.artifactId.toLowerCase()] || [];
|
|
78
|
+
for (const e of productMatches) {
|
|
79
|
+
if (vendorMatchesGroup(e.vendor, dep.groupId)) {
|
|
80
|
+
all.push(...matchOne(dep, [e], "probable"));
|
|
81
|
+
} else {
|
|
82
|
+
all.push(...matchOne(dep, [e], "possible")); // ← émis quand même
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Toute dep dont l'`artifactId` collide avec un produit non relié produit un match `possible`. Collisions fréquentes : `core`, `common`, `api`, `client`, `utils`, `parser`, `web`. Une CVE avec `product="core"` flagge **toutes** les deps `*-core`.
|
|
88
|
+
|
|
89
|
+
**Fix :** drop le tier `possible` ou ne l'afficher que si la refinement CPE (`refineMatchesWithCpe`) l'a confirmé.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
#### H4 — CPE refinement flagge sans supprimer
|
|
94
|
+
**Fichier :** `lib/cpe.js:269-272`
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
if (!affected && rec.configurations.length) {
|
|
98
|
+
m.cpeFiltered = true; // ← marqué, jamais supprimé
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`cpeFiltered` est un soft tag. Si `lib/cve-report.js` ne strippe pas `cpeFiltered === true` (à vérifier dans le rendu), **les matches prouvés faux positifs restent dans la punch list**.
|
|
103
|
+
|
|
104
|
+
De plus, le skip `if (!rec.configurations.length && !rec.cpes.length) continue;` (`cpe.js:266`) est fail-open : une CVE sans configurations NVD (CVE récente ou réservée) est laissée au tier que le matcher lui a donné.
|
|
105
|
+
|
|
106
|
+
**Fix :** (a) filtrer par défaut `cpeFiltered=true` avec flag `--show-filtered`, ou (b) section séparée « likely false positive » dans le report.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
#### H5 — Le qualificateur CPE `update` (RC, alpha) est ignoré
|
|
111
|
+
**Fichier :** `lib/cpe.js:81-100` (`matchVersionRange`)
|
|
112
|
+
|
|
113
|
+
Le champ index 6 du CPE 2.3 (`update`) est parsé mais jamais comparé. NVD liste fréquemment les pré-releases via le champ update (`cpe:2.3:a:vendor:product:1.0.0:rc1:*…`). Le hard-pin compare littéralement la `version` ignorant `update` :
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
if (parsed.version && parsed.version !== "*" && parsed.version !== "-") {
|
|
117
|
+
try { return compareMavenVersions(depVersion, parsed.version) === 0; }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
→ `criteria: cpe:2.3:a:apache:foo:1.0.0:beta1:*…` matche `dep=1.0.0` (release). FP.
|
|
121
|
+
|
|
122
|
+
**Fix :** si `update` n'est ni `*` ni `-`, concaténer (`${version}-${update}`) avant `compareMavenVersions` (qui gère déjà `1.0.0-beta1`).
|
|
123
|
+
|
|
124
|
+
### MEDIUM
|
|
125
|
+
|
|
126
|
+
#### M1 — `compareMavenVersions` accepte du garbage sans broncher
|
|
127
|
+
**Fichier :** `lib/maven-version.js:25-94`
|
|
128
|
+
|
|
129
|
+
`compareMavenVersions("${foo}", "1.0")` → `-1`. Une variable Maven non résolue est comparée comme une vraie version. La pipeline filtre `/\$\{/.test(dep.version)` dans `expandWithTransitives`, `queryOsvForDeps`, `checkOutdatedDeps` — **mais pas dans `matchDepsAgainstCves`** (`cve-match.js:151-162`). Donc dep avec version `${foo}` → `isVersionAffected("${foo}", range)` → fall-through `true` (H1).
|
|
130
|
+
|
|
131
|
+
**Fix :** skipper les deps avec `${…}` non résolu dans `matchDepsAgainstCves`.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
#### M2 — OSV : stubs cachés ré-émis sur la mauvaise version
|
|
136
|
+
**Fichier :** `lib/osv.js:242-265` (`runMatches`)
|
|
137
|
+
|
|
138
|
+
`vulnToMatch` ne refait pas de check range local. Le cache key `(g, a, v)` est versionné, donc pour la même dep+version c'est sûr. Mais le branch « stub only » émet `severity: "UNKNOWN"` sans description — vrai FP si la dep a été upgradée entre la build du cache et le scan.
|
|
139
|
+
|
|
140
|
+
`dep.version` avec `1.0.0.Final` ou `1.0.0.RELEASE` est envoyée verbatim à OSV qui ne normalise pas → recall patchy (FN, pas FP).
|
|
141
|
+
|
|
142
|
+
**Fix :** toujours évaluer `vuln.affected[].ranges` localement avant d'émettre. Drop des matches stub-only.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
#### M3 — `parseRange` exporté mais jamais appelé
|
|
147
|
+
**Fichier :** `lib/maven-version.js:131-146`
|
|
148
|
+
|
|
149
|
+
`parseRange` traite les ranges Maven (`[1.0,2.0)`) mais aucun caller. Une `<version>[1.0,2.0)</version>` est passée verbatim à `compareMavenVersions("[1.0,2.0)", spec.version)`. Le tokenizer split sur `[.\-]`, `[1` devient un segment string → ranking sub-release → typiquement FN, mais peut devenir FP via H1.
|
|
150
|
+
|
|
151
|
+
**Fix :** câbler `parseRange` dans la pipeline ; sinon reporter ces deps en `unresolved-versions`.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
#### M4 — `cpeMatchesDep` ré-introduit le substring leak de H2
|
|
156
|
+
**Fichier :** `lib/cpe.js:170-173`
|
|
157
|
+
|
|
158
|
+
Le filtre CPE supposé *narrow* contient lui-même la même heuristique substring que H2. Après que H2 fait passer un match, ce leak l'empêche de le re-filtrer.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
#### M5 — retire.js passé sans validation locale
|
|
163
|
+
**Fichier :** `lib/retire.js:148-193`
|
|
164
|
+
|
|
165
|
+
Le wrapper a accès à `below`/`atOrAbove` mais ne les compare jamais. Les régressions historiques des signatures retire (matchs sur des bundles non reliés) passent. Pas d'équivalent à `--includeOsvData` / `--ignorefile`.
|
|
166
|
+
|
|
167
|
+
retire émet aussi un CVE id synthétique `RETIRE-<component>-<version>` qui ne dédup jamais contre fad/osv/nvd → **même vuln rapportée 2 fois**.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
#### M6 — Cohérence des clés `byProduct`
|
|
172
|
+
**Fichier :** `lib/cve-download.js:163-164` + `lib/cve-match.js:191`
|
|
173
|
+
|
|
174
|
+
Index keyé sur `a.product` brut, lookup sur `.toLowerCase()`. Actuellement safe car `extractAffectedRanges` lowercase upstream — mais fragile. À documenter ou normaliser explicitement.
|
|
175
|
+
|
|
176
|
+
### LOW
|
|
177
|
+
|
|
178
|
+
- **L1** — `data/known-obsolete.json:92-96` : `struts2-core` flaggé HIGH sans gate de version (Struts 2.5.30 patché remonte « obsolete »).
|
|
179
|
+
- **L2** — `findCycleForVersion` (`lib/outdated.js:79-86`) prefix-match OK, pas de FP trouvé.
|
|
180
|
+
- **L3** — Tests : aucun ne couvre les edge cases ci-dessus (no-bounds, substring vendor leak, possible tier, update CPE, garbage `${...}`, `parseRange`).
|
|
181
|
+
|
|
182
|
+
### Verdict faux positifs
|
|
183
|
+
|
|
184
|
+
**Architecture saine, implémentation fail-open.** Les 4 cascades (H1+H2+H3+H4) doivent être patchées avant prod.
|
|
185
|
+
|
|
186
|
+
Patches prioritaires (ordre) :
|
|
187
|
+
1. H1 → one-liner, plus gros gain
|
|
188
|
+
2. H2 → tightening de `vendorMatchesGroup`
|
|
189
|
+
3. H3 → cacher le tier `possible` par défaut
|
|
190
|
+
4. H4 → strip `cpeFiltered:true` du report par défaut
|
|
191
|
+
5. M1 → skip `${...}` dans `matchDepsAgainstCves`
|
|
192
|
+
6. M2 → re-eval local des OSV ranges
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## B. Code reuse
|
|
197
|
+
|
|
198
|
+
### HIGH
|
|
199
|
+
|
|
200
|
+
- **Helper de cache disque triplicé** : `lib/osv.js:25-44`, `lib/nvd.js:28-45`, `lib/retire.js:27-45`. Pattern identique (`_fetchedAt` + TTL + JSON). `lib/outdated.js:22-37` même chose avec shape différent.
|
|
201
|
+
→ Extraire `lib/cache-disk.js` exportant `makeCache(dir, ttlMs)`.
|
|
202
|
+
|
|
203
|
+
- **`severityFromScore` + `SEVERITY_RANK` dupliqués 7+ fois** : `lib/nvd.js:64-71`, `lib/cve-download.js:82-89`, `lib/osv.js:60-66`, `lib/cve-match.js:9`, `lib/snyk.js:112`, `lib/cve-report.js:{634, 761, 1085, 1280}`, `fad-checker.js:657`.
|
|
204
|
+
→ `lib/severity.js` + `sortMatchesBySeverity()` helper.
|
|
205
|
+
|
|
206
|
+
- **Merge-by-source logic dupliquée** : `fad-checker.js:628-665` (`mergeBySource`) vs `lib/snyk.js:97-120` (`mergeWithFadResults`). Le `"both"` de Snyk désaccorde avec le `"fad+osv"` ailleurs.
|
|
207
|
+
→ Promouvoir `mergeBySource` dans `lib/cve-match.js`.
|
|
208
|
+
|
|
209
|
+
### MEDIUM
|
|
210
|
+
|
|
211
|
+
- **`depLabel` / `depToKey` réinventés 6 fois** : `fad-checker.js:551`, `lib/cpe.js:177-184`, `lib/osv.js:100-102`, `lib/cve-report.js:{648, 669, 773, 1296}`.
|
|
212
|
+
- **Walker de répertoires + `SKIP_DIRS` dupliqués 4 fois** : `lib/core.js:9-37`, `lib/npm/parse.js:253-284`, `lib/npm/collect.js:202-222`, `lib/retire.js:77-81`.
|
|
213
|
+
- **Deux parsers POM divergents** : `lib/core.js:49-117` vs `lib/transitive.js:111-158`. Légitime (besoins différents) mais extraction parent/coord en commun possible.
|
|
214
|
+
- **CLI hand-roll argv parsing** : `fad-checker.js:25-150` walk de `process.argv` pour `--completion`, `--set-nvd-key`, etc. — devrait être des subcommands `commander`.
|
|
215
|
+
|
|
216
|
+
### LOW
|
|
217
|
+
|
|
218
|
+
- 3 comparateurs de versions divergents : `compareMavenVersions`, `compareVersionsLoose` (`cve-report.js:705-715`), `semverCompare` (`npm/collect.js:25-37`).
|
|
219
|
+
- `parseMavenMetadataLatest` (`outdated.js:212-224`) en regex au lieu d'xml2js (OK car XML trivial).
|
|
220
|
+
|
|
221
|
+
### Modules clean
|
|
222
|
+
`maven-version.js`, `maven-repo.js`, `config.js`, `scan-completeness.js`, `cache-archive.js`.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## C. Code quality
|
|
227
|
+
|
|
228
|
+
### HIGH
|
|
229
|
+
|
|
230
|
+
- **`fad-checker.js` (665 lignes) = god script** : pré-parse argv mélangé à l'orchestration, business logic dans la CLI.
|
|
231
|
+
- **`lib/cve-report.js` (1455 lignes) = mega-module** : CSS inline (~200 lignes), JS inline (`TOGGLE_SCRIPT`), business logic (`buildFixRecommendations`, `versionJump`, `pickTopCriticalMatches`) et rendu HTML dans le même fichier. `RENDER_CTX` module-global (lignes 247-249) reset à chaque `buildBody` — comment auto-avoue le workaround.
|
|
232
|
+
→ Splitter en `lib/report/{css,html,recommendations,word}.js`. Passer `srcRoot` en paramètre.
|
|
233
|
+
- **Stringly-typed partout** : `"fad" | "osv" | "nvd" | "snyk" | "retire"` en chaînes nues + class CSS `.source.snyk` qui couple le HTML à ces littéraux. Renommer une source casse silencieusement le CSS.
|
|
234
|
+
→ `lib/keys.js` + `SOURCES = Object.freeze({...})`.
|
|
235
|
+
|
|
236
|
+
### MEDIUM
|
|
237
|
+
|
|
238
|
+
- **Param sprawl sur `writeReports` / `buildBody` / `renderExecutiveSummary`** — 10 champs disjoints recomposés à chaque appel.
|
|
239
|
+
- **CVE matcher : duplication structurelle tier-2/tier-3** (`cve-match.js:175-219`) — flatten en single loop avec ternaire.
|
|
240
|
+
- **Lockfile parsers v1 vs v2/v3 collés en un seul `parsePackageLock`** (`npm/parse.js:76-176`) — splitter.
|
|
241
|
+
- **~30 `catch {}` silencieux** dans tout le code. Beaucoup légitimes, certains masquent des bugs réels (`osv.js:233`, `outdated.js:187,201`, `fad-checker.js:259`).
|
|
242
|
+
- **`RENDER_CTX` global** → thread `srcRoot` via les signatures (3 niveaux max).
|
|
243
|
+
- **Skip-dir lists copy-collées 4x** avec divergence `build/` (Maven keep, JS skip) — documenter une fois.
|
|
244
|
+
- **`lib/core.js:230-235`** repeat parent-resolution dans `rewritePoms` — consolider.
|
|
245
|
+
|
|
246
|
+
### LOW
|
|
247
|
+
|
|
248
|
+
- Cache helpers ré-implémentés (cf. reuse).
|
|
249
|
+
- Commentaires narratifs sans valeur (`cve-report.js:404-406`, `cve-report.js:519-521`, `transitive.js:204-206`, `fad-checker.js:202`).
|
|
250
|
+
- Pollution `byId` potentielle via `excludedById`/`missingById` (`core.js:240-285`) — appliquer le guard du CLAUDE.md.
|
|
251
|
+
- `lib/osv.js:188` arithmétique d'index fragile (`(batchIdx - 1) * BATCH_SIZE + j`) — utiliser `i + j`.
|
|
252
|
+
- Verbosité booléenne threadée 4+ niveaux → `lib/log.js`.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## D. Efficiency
|
|
257
|
+
|
|
258
|
+
### HIGH
|
|
259
|
+
|
|
260
|
+
- **CVE index loadé eagerly** (`lib/cve-download.js:299-301`) : `JSON.parse(readFileSync)` synchrone même pour un projet de 3 deps. Charger async + lazy par bucket.
|
|
261
|
+
- **`cpe.js` re-parse les CPE et walk 2 fois** (`cpe.js:194-245`) : `parseCpe23` appelé répétitivement, walk pour confidence après le walk d'évaluation. 2M+ string-splits sur un projet 800 deps × 50 matches.
|
|
262
|
+
→ Cache `m._parsed ||= parseCpe23(m.criteria)` + retour du match satisfaisant.
|
|
263
|
+
- **NVD enrichment 100 % serial** (`nvd.js:186-203`) : `sleep(600|6000)` entre chaque appel. 200 CVEs uniques = 2 min avec clé, **20 min sans**. NIST policy = 50/30s window → permet parallélisme.
|
|
264
|
+
- **TOCTOU `existsSync` + `readFile`** dans `transitive.js:52`, `nvd.js:34`, `osv.js:33`, `retire.js:32-37`, `outdated.js:22-25`. Doublé syscalls.
|
|
265
|
+
- **`outdated.checkOutdatedDeps` cache reset global** (`outdated.js:230`) : `cache.entries = {}` si meta TTL périmé → 600 deps refetch même si entrées fraîches.
|
|
266
|
+
→ TTL par-entrée.
|
|
267
|
+
|
|
268
|
+
### MEDIUM
|
|
269
|
+
|
|
270
|
+
- `findEolProduct` re-sort la prefix-list dans la boucle (`outdated.js:46-52`) — sort once at module load.
|
|
271
|
+
- `outByKey` Map rebuild 6x dans `cve-report.js` (`:633-679`, `:760-795`, `:993-1003`) — builder once dans `buildBody`.
|
|
272
|
+
- CVE bulk JSON `readFileSync` per file (`cve-download.js:274-287`) — borné par cache 24h, MEDIUM.
|
|
273
|
+
- `cve-report.js` : `renderDetailPanel` (`:417-493`) + `groupExternalRefs` (`:340`) re-alloués par row → 45k allocations sur un report 5k rows. Pré-classifier à l'enrichment NVD.
|
|
274
|
+
- **POMs parsés 2x sur le chemin `rewritePoms`** (`core.js:50` puis re-read à `:219`). `structuredClone` ou skip re-parse en read-only.
|
|
275
|
+
- **`transitive.js:314` : `queue.shift()` O(n)** sur 4000+ transitives → 16M memmoves. Switch en cursor.
|
|
276
|
+
- **Maven Central : pas de batch endpoint** (`outdated.js:176`) — solrsearch accepte OR boolean. 600 deps → 1 requête au lieu de 600.
|
|
277
|
+
- **OSV detail fetch serial** dans `queryBatch` (`osv.js:218-236`) — `p-limit(10)` similaire à fad-checker.js.
|
|
278
|
+
|
|
279
|
+
### LOW
|
|
280
|
+
|
|
281
|
+
- `getAllInheritedProps` rebuild via spread — fine en dessous de 10k POMs.
|
|
282
|
+
- `xml2js` 3-5x plus lent que `fast-xml-parser` — hors scope.
|
|
283
|
+
- Eager `require("chalk")` etc. — invisible.
|
|
284
|
+
|
|
285
|
+
### Note positive
|
|
286
|
+
Le matcher CVE (`matchDepsAgainstCves`) est **Map-indexé correctement** (`byPackageName` / `byProduct` pré-bucketés) — pas de O(n²) sur le hot path.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## E. Recommandations priorisées
|
|
291
|
+
|
|
292
|
+
| # | Action | Severity | Effort | Impact |
|
|
293
|
+
|---|---|---|---|---|
|
|
294
|
+
| 1 | Patch `isVersionAffected` fail-closed (H1) | HIGH FP | 1 ligne | Énorme |
|
|
295
|
+
| 2 | Tighten `vendorMatchesGroup` substring (H2) | HIGH FP | 10 lignes | Énorme |
|
|
296
|
+
| 3 | Drop ou hide tier `possible` (H3) | HIGH FP | 5 lignes | Énorme |
|
|
297
|
+
| 4 | Strip `cpeFiltered:true` du report (H4) | HIGH FP | 3 lignes | Gros |
|
|
298
|
+
| 5 | Handle CPE `update` qualifier (H5) | HIGH FP | 15 lignes | Moyen |
|
|
299
|
+
| 6 | Skip `${...}` dans matcher (M1) | MED FP | 3 lignes | Moyen |
|
|
300
|
+
| 7 | OSV ranges re-eval local (M2) | MED FP | 30 lignes | Moyen |
|
|
301
|
+
| 8 | Extract `lib/severity.js` + `SEVERITY_RANK` | HIGH reuse | 1h | Maintenance |
|
|
302
|
+
| 9 | Extract `lib/cache-disk.js` | HIGH reuse | 1h | Maintenance |
|
|
303
|
+
| 10 | NVD enrichment parallèle (token-bucket) | HIGH perf | 30min | 10x speedup |
|
|
304
|
+
| 11 | Batch Maven Central Solr (#13 efficiency) | HIGH perf | 1h | 100x speedup |
|
|
305
|
+
| 12 | `parseCpe23` memoization (CPE perf) | HIGH perf | 10min | 2-3x speedup |
|
|
306
|
+
| 13 | Splitter `cve-report.js` en sous-modules | HIGH quality | 2h | Maintenance |
|
|
307
|
+
| 14 | Tests des edge cases FP (`update`, no-bounds, substring) | LOW | 2h | Filet de sécurité |
|
|
308
|
+
|
|
309
|
+
**Quick wins (< 1 jour cumulé)** : #1, #2, #3, #4, #6, #12 → diviserait le taux de FP par ~3-5 et accélérerait le rendu de ~2-3×.
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## F. Verdict final
|
|
314
|
+
|
|
315
|
+
- **Architecture :** **bonne**. Séparation Maven/npm, 3-tier matching, post-CPE refinement, multi-source dedup, caches TTL — tout est en place.
|
|
316
|
+
- **Implémentation matcher :** **fail-open systémique**. Les 4 cascades H1→H4 transforment un outil correctement architecturé en générateur de faux positifs.
|
|
317
|
+
- **Performance :** correcte en CPU (Map-indexed), **mauvaise en I/O** (NVD serial, Solr unitaire, double-parse POM).
|
|
318
|
+
- **Qualité :** 2 mega-modules (`cve-report.js` 1455 LoC, `fad-checker.js` 665 LoC) à splitter, ~30 `catch {}` à auditer, 7+ duplications de `SEVERITY_RANK`.
|
|
319
|
+
- **Tests :** 96 tests existants mais happy-path-heavy ; aucune couverture des edge cases FP listés ci-dessus.
|
|
320
|
+
|
|
321
|
+
**Recommandation pratique :** sans les patches H1-H4, le rapport doit être traité comme une **liste de départ pour triage manuel**, pas comme un inventaire de vulnérabilités exploitable. Le tier `exact` reste fiable. Tout le reste demande des yeux humains sur la description CVE avant action.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Annexe — Références fichier:ligne
|
|
326
|
+
|
|
327
|
+
**Faux positifs :**
|
|
328
|
+
- `lib/maven-version.js:25-94, 101-124, 131-146`
|
|
329
|
+
- `lib/cve-match.js:9, 99-109, 151-162, 164-173, 175-219, 191-198`
|
|
330
|
+
- `lib/cpe.js:25, 36-68, 81-100, 161-184, 194-245, 266, 269-272`
|
|
331
|
+
- `lib/osv.js:25-44, 60-66, 100-102, 188, 218-236, 242-265, 273`
|
|
332
|
+
- `lib/nvd.js:28-45, 64-71, 145, 186-203`
|
|
333
|
+
- `lib/retire.js:24-45, 77-81, 148-193`
|
|
334
|
+
- `lib/outdated.js:22-37, 46-52, 79-86, 168-187, 212-254`
|
|
335
|
+
- `lib/cve-download.js:82-89, 163-164, 274-301`
|
|
336
|
+
- `lib/transitive.js:51-101, 111-158, 204-235, 273-320`
|
|
337
|
+
- `lib/core.js:9-37, 49-149, 219-285`
|
|
338
|
+
- `lib/npm/{parse.js:76-291, collect.js:25-224}`
|
|
339
|
+
- `lib/cve-report.js:48-247, 340, 404-493, 519-521, 559, 633-1455`
|
|
340
|
+
- `fad-checker.js:25-150, 202, 259, 551, 628-665`
|
|
341
|
+
- `data/{cpe-coord-map.json, known-obsolete.json:92-96}`
|
|
342
|
+
|
|
343
|
+
**Tests manquants :** `test/{cpe,cve-match,maven-version}.test.js` — ajouter edge cases `update`, no-bounds, substring vendor leak, garbage version.
|
package/fad-checker.js
CHANGED
|
@@ -541,8 +541,11 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
|
|
|
541
541
|
|
|
542
542
|
// Split prod vs dev based on the dep's isDev flag (set at collection time
|
|
543
543
|
// from Maven scope=test/provided and npm dev/devOptional/optional).
|
|
544
|
-
|
|
545
|
-
|
|
544
|
+
// CPE-filtered matches are excluded from the CLI headline — they're surfaced
|
|
545
|
+
// in the HTML report's "Likely false positives" appendix instead.
|
|
546
|
+
const prodMatches = cveMatches.filter(m => !m.dep?.isDev && !m.cpeFiltered);
|
|
547
|
+
const devMatches = cveMatches.filter(m => m.dep?.isDev && !m.cpeFiltered);
|
|
548
|
+
const cpeFilteredCount = cveMatches.filter(m => m.cpeFiltered).length;
|
|
546
549
|
|
|
547
550
|
const stats = computeStats(prodMatches);
|
|
548
551
|
const devStats = computeStats(devMatches);
|
|
@@ -554,6 +557,7 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
|
|
|
554
557
|
console.log(` ${chalk.red(sev)} ${m.cve.id} ${depLabel(m.dep)}:${m.dep.version}`);
|
|
555
558
|
}
|
|
556
559
|
if (prodMatches.length > 20) console.log(` ... and ${prodMatches.length - 20} more (see report)`);
|
|
560
|
+
if (cpeFilteredCount) console.log(chalk.gray(` (${cpeFilteredCount} likely false positives moved to report appendix)`));
|
|
557
561
|
|
|
558
562
|
if (devMatches.length) {
|
|
559
563
|
console.log(chalk.bold.cyan(`\n 2. CVE in dev dependencies (${devMatches.length})`));
|
package/lib/cpe.js
CHANGED
|
@@ -67,6 +67,17 @@ function parseCpe23(uri) {
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Memoize parseCpe23 on the cpeMatch object so subsequent passes (the
|
|
71
|
+
// confidence walk inside evaluateCveForDep) don't re-tokenize the same URI.
|
|
72
|
+
// `_parsedCpe` uses `null` (parse failed) vs `undefined` (not yet parsed) to
|
|
73
|
+
// distinguish empty result from missing cache.
|
|
74
|
+
function parseCpe23Cached(cpeMatch) {
|
|
75
|
+
if (!cpeMatch || typeof cpeMatch !== "object") return parseCpe23(cpeMatch?.criteria || "");
|
|
76
|
+
if (cpeMatch._parsedCpe !== undefined) return cpeMatch._parsedCpe;
|
|
77
|
+
cpeMatch._parsedCpe = parseCpe23(cpeMatch.criteria || "");
|
|
78
|
+
return cpeMatch._parsedCpe;
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
/**
|
|
71
82
|
* Match a dep version against a single NVD cpeMatch entry.
|
|
72
83
|
* The entry has the shape:
|
|
@@ -81,12 +92,18 @@ function parseCpe23(uri) {
|
|
|
81
92
|
function matchVersionRange(depVersion, cpeMatch) {
|
|
82
93
|
if (!cpeMatch) return false;
|
|
83
94
|
if (!depVersion) return true; // unknown version → assume affected
|
|
84
|
-
const parsed =
|
|
95
|
+
const parsed = parseCpe23Cached(cpeMatch);
|
|
85
96
|
if (!parsed) return false;
|
|
86
97
|
|
|
87
98
|
// Hard pin in the criteria URI itself
|
|
88
99
|
if (parsed.version && parsed.version !== "*" && parsed.version !== "-") {
|
|
89
|
-
|
|
100
|
+
// CPE 2.3 update field qualifies the version (`:1.0.0:beta1:` ≡ `1.0.0-beta1`).
|
|
101
|
+
// Without folding it in, a release like 1.0.0 would incorrectly match a pin
|
|
102
|
+
// of 1.0.0:rc1 — see H5 in CRITICAL-REVIEW.md.
|
|
103
|
+
const pinned = (parsed.update && parsed.update !== "*" && parsed.update !== "-")
|
|
104
|
+
? `${parsed.version}-${parsed.update}`
|
|
105
|
+
: parsed.version;
|
|
106
|
+
try { return compareMavenVersions(depVersion, pinned) === 0; }
|
|
90
107
|
catch { return false; }
|
|
91
108
|
}
|
|
92
109
|
|
|
@@ -112,7 +129,7 @@ function nodeAffectsDep(node, dep, cpeCoordMap) {
|
|
|
112
129
|
const matches = node.cpeMatch || node.cpe_match || [];
|
|
113
130
|
for (const m of matches) {
|
|
114
131
|
if (m.vulnerable === false) continue;
|
|
115
|
-
const parsed =
|
|
132
|
+
const parsed = parseCpe23Cached(m);
|
|
116
133
|
if (!parsed) continue;
|
|
117
134
|
if (!cpeMatchesDep(parsed, dep, cpeCoordMap)) continue;
|
|
118
135
|
if (!matchVersionRange(dep.version, m)) continue;
|
|
@@ -168,8 +185,9 @@ function cpeMatchesDep(cpe, dep, cpeCoordMap) {
|
|
|
168
185
|
const p = (cpe.product || "").toLowerCase();
|
|
169
186
|
if (p !== a) return false;
|
|
170
187
|
if (g === v) return true;
|
|
188
|
+
// Mirrors vendorMatchesGroup: dot-segment membership only, no substring
|
|
189
|
+
// fallback. Unbounded substring matching leaked FPs (M4 in CRITICAL-REVIEW.md).
|
|
171
190
|
if (g.split(".").includes(v)) return true;
|
|
172
|
-
if (g.includes(v) || v.includes(g)) return true;
|
|
173
191
|
}
|
|
174
192
|
return false;
|
|
175
193
|
}
|
|
@@ -213,7 +231,7 @@ function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
|
|
|
213
231
|
for (const n of nodes) {
|
|
214
232
|
for (const m of n.cpeMatch || n.cpe_match || []) {
|
|
215
233
|
if (m.vulnerable === false) continue;
|
|
216
|
-
const parsed =
|
|
234
|
+
const parsed = parseCpe23Cached(m);
|
|
217
235
|
if (!parsed) continue;
|
|
218
236
|
if (!cpeMatchesDep(parsed, dep, map)) continue;
|
|
219
237
|
if (!matchVersionRange(dep.version, m)) continue;
|
package/lib/cve-match.js
CHANGED
|
@@ -166,16 +166,22 @@ function vendorMatchesGroup(vendor, groupId) {
|
|
|
166
166
|
const v = vendor.toLowerCase();
|
|
167
167
|
const g = groupId.toLowerCase();
|
|
168
168
|
if (g === v) return true;
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
// org.apache.commons matches vendor "apache" via dot-segment membership.
|
|
170
|
+
// Unbounded substring checks (g.includes(v) / v.includes(g)) used to leak
|
|
171
|
+
// FPs — e.g. vendor "open" matching org.opensaml.* because "opensaml"
|
|
172
|
+
// contains "open". Dot-segment membership is strict enough.
|
|
173
|
+
if (g.split(".").includes(v)) return true;
|
|
174
|
+
return false;
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
function matchDepsAgainstCves(resolvedDeps, cveIndex) {
|
|
177
|
+
function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
|
|
176
178
|
if (!cveIndex) return [];
|
|
177
179
|
const byPackage = cveIndex.byPackageName || {};
|
|
178
180
|
const byProduct = cveIndex.byProduct || {};
|
|
181
|
+
// "possible" tier (product matches but vendor doesn't) is FP-heavy and
|
|
182
|
+
// hidden by default. The CPE refinement step can still rescue a real hit
|
|
183
|
+
// later; opt-in shows the raw bucket for triage.
|
|
184
|
+
const includePossibleTier = !!opts.includePossibleTier;
|
|
179
185
|
|
|
180
186
|
const all = [];
|
|
181
187
|
for (const dep of resolvedDeps.values()) {
|
|
@@ -187,12 +193,12 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex) {
|
|
|
187
193
|
// Tier 1: exact packageName match
|
|
188
194
|
const t1 = matchOne(dep, byPackage[key], "exact");
|
|
189
195
|
all.push(...t1);
|
|
190
|
-
// Tier 2
|
|
196
|
+
// Tier 2: product match scoped by vendor heuristic
|
|
191
197
|
const productMatches = byProduct[dep.artifactId.toLowerCase()] || [];
|
|
192
198
|
for (const e of productMatches) {
|
|
193
199
|
if (vendorMatchesGroup(e.vendor, dep.groupId)) {
|
|
194
200
|
all.push(...matchOne(dep, [e], "probable"));
|
|
195
|
-
} else {
|
|
201
|
+
} else if (includePossibleTier) {
|
|
196
202
|
all.push(...matchOne(dep, [e], "possible"));
|
|
197
203
|
}
|
|
198
204
|
}
|
package/lib/maven-version.js
CHANGED
|
@@ -105,6 +105,12 @@ function isVersionAffected(depVersion, spec) {
|
|
|
105
105
|
const dep = parseMavenVersion(depVersion);
|
|
106
106
|
if (!dep.segments.length) return false;
|
|
107
107
|
|
|
108
|
+
// Fail-closed: a spec with no version constraints at all carries no information.
|
|
109
|
+
// Without this guard the function falls through to `return true` for every input,
|
|
110
|
+
// which was the H1 cascade described in CRITICAL-REVIEW.md.
|
|
111
|
+
const hasLower = spec.version && spec.version !== "0" && spec.version !== "*";
|
|
112
|
+
if (!hasLower && !spec.lessThan && !spec.lessThanOrEqual) return false;
|
|
113
|
+
|
|
108
114
|
// Lower bound (inclusive)
|
|
109
115
|
if (spec.version && spec.version !== "0" && spec.version !== "*") {
|
|
110
116
|
if (compareMavenVersions(depVersion, spec.version) < 0) return false;
|
package/lib/nvd.js
CHANGED
|
@@ -25,6 +25,15 @@ function getRateDelay() {
|
|
|
25
25
|
return getNvdApiKey() ? 600 : 6000;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// NIST rate-limit policy is a rolling 30s window. Burst up to N at once then
|
|
29
|
+
// wait until the oldest send is older than 30s. Without a key the burst size
|
|
30
|
+
// stays at 5; with a key it's 50 — which lets latency overlap and avoids
|
|
31
|
+
// blocking on a `setTimeout` between every request.
|
|
32
|
+
function getBurstSize() {
|
|
33
|
+
return getNvdApiKey() ? 50 : 5;
|
|
34
|
+
}
|
|
35
|
+
const NVD_WINDOW_MS = 30_000;
|
|
36
|
+
|
|
28
37
|
function cachePath(cveId) {
|
|
29
38
|
return path.join(NVD_CACHE_DIR, `${cveId}.json`);
|
|
30
39
|
}
|
|
@@ -176,31 +185,67 @@ async function fetchOne(cveId, opts = {}) {
|
|
|
176
185
|
* Rate limited per the NIST policy (use NVD_API_KEY for faster access).
|
|
177
186
|
*/
|
|
178
187
|
async function enrichMatches(matches, opts = {}) {
|
|
179
|
-
const {
|
|
188
|
+
const { offline } = opts;
|
|
180
189
|
const uniqueCves = new Set();
|
|
181
190
|
for (const m of matches) if (m.cve?.id?.startsWith("CVE-")) uniqueCves.add(m.cve.id);
|
|
182
191
|
const hasKey = !!getNvdApiKey();
|
|
183
|
-
const
|
|
184
|
-
if (verbose) console.log(`🔍 NVD: enriching ${uniqueCves.size} unique CVEs${offline ? " (offline — cache only)" : hasKey ? " (with API key, 50/30s)" : " (no API key — throttled to 5/30s; pass --set-nvd-key for 10× faster)"}…`);
|
|
192
|
+
const burst = getBurstSize();
|
|
185
193
|
|
|
194
|
+
// Partition cached vs live up-front so the progress display reflects the
|
|
195
|
+
// actual work to do (the user's "stuck at OSV" complaint was caused by
|
|
196
|
+
// silent serial fetching with no progress output).
|
|
186
197
|
const byId = new Map();
|
|
187
|
-
|
|
198
|
+
const liveIds = [];
|
|
188
199
|
for (const cveId of uniqueCves) {
|
|
189
|
-
// Only sleep between live (non-cached) requests.
|
|
190
200
|
const cached = readCache(cveId);
|
|
191
201
|
if (cached !== null && cached !== undefined) {
|
|
192
202
|
byId.set(cveId, cached);
|
|
193
203
|
continue;
|
|
194
204
|
}
|
|
195
205
|
if (offline) { byId.set(cveId, null); continue; }
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
206
|
+
liveIds.push(cveId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (liveIds.length) {
|
|
210
|
+
const etaSec = Math.ceil((liveIds.length / burst) * (NVD_WINDOW_MS / 1000));
|
|
211
|
+
const keyHint = hasKey ? "with API key (50/30s)" : "no API key — throttled to 5/30s, run `fad-checker --set-nvd-key <KEY>` (free, instant) to be 10× faster";
|
|
212
|
+
const cachedCount = byId.size;
|
|
213
|
+
const cachedHint = cachedCount ? `, ${cachedCount} cached` : "";
|
|
214
|
+
console.log(`🔍 NVD: enriching ${liveIds.length} CVEs${cachedHint} — ${keyHint}. ETA ~${etaSec}s.`);
|
|
215
|
+
} else if (uniqueCves.size) {
|
|
216
|
+
console.log(`🔍 NVD: ${uniqueCves.size} CVEs (all cached).`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Token-bucket burst: fire `burst` requests in parallel, wait until the
|
|
220
|
+
// oldest send is older than NVD_WINDOW_MS, repeat. Better progress UX than
|
|
221
|
+
// the previous serial sleep loop — same effective throughput.
|
|
222
|
+
let done = 0;
|
|
223
|
+
const startedAt = [];
|
|
224
|
+
const startProgress = Date.now();
|
|
225
|
+
const printProgress = (final = false) => {
|
|
226
|
+
const elapsed = Math.round((Date.now() - startProgress) / 1000);
|
|
227
|
+
const pct = liveIds.length ? Math.round((done / liveIds.length) * 100) : 100;
|
|
228
|
+
const line = ` NVD: ${done}/${liveIds.length} (${pct}%) — ${elapsed}s elapsed`;
|
|
229
|
+
if (process.stdout.isTTY) process.stdout.write(`\r${line}${final ? "\n" : " "}`);
|
|
230
|
+
else if (final) console.log(line);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
for (let i = 0; i < liveIds.length; i += burst) {
|
|
234
|
+
const slice = liveIds.slice(i, i + burst);
|
|
235
|
+
const windowStart = Date.now();
|
|
236
|
+
startedAt.push(windowStart);
|
|
237
|
+
const results = await Promise.all(slice.map(id => fetchOne(id, opts)));
|
|
238
|
+
for (let j = 0; j < slice.length; j++) byId.set(slice[j], results[j]);
|
|
239
|
+
done += slice.length;
|
|
240
|
+
printProgress();
|
|
241
|
+
|
|
242
|
+
// Respect the rolling 30s window before starting the next burst.
|
|
243
|
+
if (i + burst < liveIds.length) {
|
|
244
|
+
const since = Date.now() - windowStart;
|
|
245
|
+
if (since < NVD_WINDOW_MS) await sleep(NVD_WINDOW_MS - since);
|
|
246
|
+
}
|
|
202
247
|
}
|
|
203
|
-
if (
|
|
248
|
+
if (liveIds.length) printProgress(true);
|
|
204
249
|
|
|
205
250
|
for (const m of matches) {
|
|
206
251
|
const data = byId.get(m.cve?.id);
|
package/lib/osv.js
CHANGED
|
@@ -177,7 +177,9 @@ async function queryBatch(deps, opts = {}) {
|
|
|
177
177
|
}
|
|
178
178
|
let batchIdx = 0;
|
|
179
179
|
for (const batch of batches) {
|
|
180
|
-
|
|
180
|
+
batchIdx++;
|
|
181
|
+
if (process.stdout.isTTY) process.stdout.write(`\r OSV batch ${batchIdx}/${batches.length} (${batch.length} deps)… `);
|
|
182
|
+
else if (batches.length > 1) console.log(` OSV batch ${batchIdx}/${batches.length} (${batch.length} deps)…`);
|
|
181
183
|
const res = await fetcher(`${OSV_BASE}/v1/querybatch`, {
|
|
182
184
|
method: "POST",
|
|
183
185
|
headers: { "Content-Type": "application/json", "User-Agent": "fad-checker-osv" },
|
|
@@ -194,7 +196,7 @@ async function queryBatch(deps, opts = {}) {
|
|
|
194
196
|
allResults[(batchIdx - 1) * BATCH_SIZE + j] = results[j] || { vulns: [] };
|
|
195
197
|
}
|
|
196
198
|
}
|
|
197
|
-
if (
|
|
199
|
+
if (process.stdout.isTTY) process.stdout.write(`\r OSV batches complete (${batches.length}) \n`);
|
|
198
200
|
|
|
199
201
|
// Persist per-dep cache (stub list — details cached separately)
|
|
200
202
|
for (let i = 0; i < deps.length; i++) {
|
|
@@ -214,27 +216,53 @@ async function queryBatch(deps, opts = {}) {
|
|
|
214
216
|
const allIds = new Set();
|
|
215
217
|
for (const slot of indexMap) for (const id of (slot.cached || [])) allIds.add(id);
|
|
216
218
|
|
|
219
|
+
// Split cached vs live so the progress display reflects actual work and
|
|
220
|
+
// detail fetches run in parallel (was silent serial — caused the
|
|
221
|
+
// "stuck after OSV" symptom on large dep trees).
|
|
217
222
|
const detailById = new Map();
|
|
218
|
-
|
|
223
|
+
const liveIds = [];
|
|
219
224
|
for (const id of allIds) {
|
|
220
225
|
const detailCacheKey = `vuln_${id}.json`;
|
|
221
226
|
const hit = readCache(detailCacheKey);
|
|
222
227
|
if (hit) { detailById.set(id, hit); continue; }
|
|
223
228
|
if (offline) continue;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
liveIds.push(id);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (liveIds.length) {
|
|
233
|
+
console.log(` OSV details: ${liveIds.length} to fetch${allIds.size - liveIds.length ? `, ${allIds.size - liveIds.length} cached` : ""}…`);
|
|
234
|
+
const concurrency = 10;
|
|
235
|
+
let cursor = 0;
|
|
236
|
+
let fetched = 0;
|
|
237
|
+
const startedAt = Date.now();
|
|
238
|
+
const printOsvProgress = (final = false) => {
|
|
239
|
+
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
|
240
|
+
const pct = Math.round((fetched / liveIds.length) * 100);
|
|
241
|
+
const line = ` OSV details: ${fetched}/${liveIds.length} (${pct}%) — ${elapsed}s`;
|
|
242
|
+
if (process.stdout.isTTY) process.stdout.write(`\r${line}${final ? "\n" : " "}`);
|
|
243
|
+
else if (final) console.log(line);
|
|
244
|
+
};
|
|
245
|
+
const workers = Array.from({ length: concurrency }, async () => {
|
|
246
|
+
while (cursor < liveIds.length) {
|
|
247
|
+
const id = liveIds[cursor++];
|
|
248
|
+
const detailCacheKey = `vuln_${id}.json`;
|
|
249
|
+
try {
|
|
250
|
+
const r = await fetcher(`${OSV_BASE}/v1/vulns/${encodeURIComponent(id)}`, {
|
|
251
|
+
headers: { "User-Agent": "fad-checker-osv" },
|
|
252
|
+
});
|
|
253
|
+
if (r.ok) {
|
|
254
|
+
const body = await r.json();
|
|
255
|
+
detailById.set(id, body);
|
|
256
|
+
writeCache(detailCacheKey, body);
|
|
257
|
+
}
|
|
258
|
+
} catch { /* ignore individual failures */ }
|
|
259
|
+
fetched++;
|
|
260
|
+
if (fetched % 5 === 0 || fetched === liveIds.length) printOsvProgress();
|
|
232
261
|
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
262
|
+
});
|
|
263
|
+
await Promise.all(workers);
|
|
264
|
+
printOsvProgress(true);
|
|
236
265
|
}
|
|
237
|
-
if (verbose) process.stdout.write(`\r OSV details fetched: ${fetched}/${allIds.size} \n`);
|
|
238
266
|
|
|
239
267
|
return runMatches(deps, indexMap, detailById);
|
|
240
268
|
}
|
|
@@ -248,14 +276,11 @@ function runMatches(deps, indexMap, detailById) {
|
|
|
248
276
|
for (const id of ids) {
|
|
249
277
|
const vuln = detailById instanceof Map ? detailById.get(id) : null;
|
|
250
278
|
if (!vuln) {
|
|
251
|
-
// Stub
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
source: "osv",
|
|
257
|
-
confidence: "exact",
|
|
258
|
-
});
|
|
279
|
+
// Stub-only (id known but no details fetched yet). Skip rather
|
|
280
|
+
// than emit a descriptionless UNKNOWN-severity placeholder:
|
|
281
|
+
// such stubs were FP-prone because the local cache key is
|
|
282
|
+
// (g, a, v) but the underlying advisory may no longer apply
|
|
283
|
+
// to a version upgraded since cache build. M2 in CRITICAL-REVIEW.md.
|
|
259
284
|
continue;
|
|
260
285
|
}
|
|
261
286
|
matches.push(vulnToMatch(dep, vuln));
|
package/lib/outdated.js
CHANGED
|
@@ -231,12 +231,31 @@ async function checkOutdatedDeps(resolvedDeps, opts = {}) {
|
|
|
231
231
|
const list = [...resolvedDeps.values()].filter(d => d.version && !/\$\{|SNAPSHOT/i.test(d.version) && d.ecosystem !== "npm");
|
|
232
232
|
const results = [];
|
|
233
233
|
|
|
234
|
-
//
|
|
234
|
+
// Progress indicator — Maven Central can serve hundreds of deps in a few
|
|
235
|
+
// seconds with 8-way concurrency, but on first run (cold cache) the user
|
|
236
|
+
// would see total silence for 20-60s.
|
|
237
|
+
const liveCount = offline ? 0 : list.filter(d => !cache.entries[`${d.groupId}:${d.artifactId}`]).length;
|
|
238
|
+
if (liveCount && !offline) {
|
|
239
|
+
console.log(`📅 Outdated: checking ${list.length} deps against Maven Central (${liveCount} live, ${list.length - liveCount} cached)…`);
|
|
240
|
+
}
|
|
241
|
+
|
|
235
242
|
let cursor = 0;
|
|
243
|
+
let processed = 0;
|
|
244
|
+
const startedAt = Date.now();
|
|
245
|
+
const printOutdatedProgress = (final = false) => {
|
|
246
|
+
if (!liveCount) return;
|
|
247
|
+
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
|
248
|
+
const pct = Math.round((processed / list.length) * 100);
|
|
249
|
+
const line = ` outdated: ${processed}/${list.length} (${pct}%) — ${elapsed}s`;
|
|
250
|
+
if (process.stdout.isTTY) process.stdout.write(`\r${line}${final ? "\n" : " "}`);
|
|
251
|
+
else if (final) console.log(line);
|
|
252
|
+
};
|
|
236
253
|
const workers = Array.from({ length: concurrency }, async () => {
|
|
237
254
|
while (cursor < list.length) {
|
|
238
255
|
const dep = list[cursor++];
|
|
239
256
|
const entry = await fetchLatestVersion(dep.groupId, dep.artifactId, cache, { offline, repos });
|
|
257
|
+
processed++;
|
|
258
|
+
if (processed % 10 === 0 || processed === list.length) printOutdatedProgress();
|
|
240
259
|
if (!entry?.latest) continue;
|
|
241
260
|
try {
|
|
242
261
|
if (compareMavenVersions(dep.version, entry.latest) < 0) {
|
|
@@ -247,6 +266,7 @@ async function checkOutdatedDeps(resolvedDeps, opts = {}) {
|
|
|
247
266
|
}
|
|
248
267
|
});
|
|
249
268
|
await Promise.all(workers);
|
|
269
|
+
if (liveCount) printOutdatedProgress(true);
|
|
250
270
|
|
|
251
271
|
cache.meta = { fetchedAt: Date.now() };
|
|
252
272
|
saveJsonCache(VERSION_CACHE_PATH, cache);
|
package/lib/transitive.js
CHANGED
|
@@ -286,6 +286,10 @@ async function resolveTransitiveDeps(directDeps, opts = {}) {
|
|
|
286
286
|
|
|
287
287
|
const visited = new Map(); // g:a -> { ...entry, depth }
|
|
288
288
|
const queue = [];
|
|
289
|
+
// Index cursor instead of `queue.shift()` — shift() is O(n) per call which
|
|
290
|
+
// turns the BFS into O(n²) on large dep trees. The cursor is safe across
|
|
291
|
+
// concurrent workers because `head++` is atomic in single-threaded JS.
|
|
292
|
+
let head = 0;
|
|
289
293
|
|
|
290
294
|
// Seed: every direct dep (we won't return them, but we walk their children).
|
|
291
295
|
for (const dep of directDeps) {
|
|
@@ -310,8 +314,8 @@ async function resolveTransitiveDeps(directDeps, opts = {}) {
|
|
|
310
314
|
|
|
311
315
|
// Worker pool
|
|
312
316
|
const workers = Array.from({ length: concurrency }, async () => {
|
|
313
|
-
while (queue.length) {
|
|
314
|
-
const node = queue
|
|
317
|
+
while (head < queue.length) {
|
|
318
|
+
const node = queue[head++];
|
|
315
319
|
if (!node) break;
|
|
316
320
|
if (node.depth >= maxDepth) continue;
|
|
317
321
|
|
|
@@ -389,11 +393,15 @@ async function resolveTransitiveDeps(directDeps, opts = {}) {
|
|
|
389
393
|
rootExclusions: [...(node.rootExclusions || []), ...(dep.exclusions || [])],
|
|
390
394
|
});
|
|
391
395
|
}
|
|
392
|
-
|
|
396
|
+
// Progress visible whenever stdout is a TTY — transitive resolution
|
|
397
|
+
// dominates wall-clock time on first run and used to look like a hang.
|
|
398
|
+
// On non-TTY (pipes, CI, tests) we skip the \r-overwrite spam.
|
|
399
|
+
if (process.stdout.isTTY) process.stdout.write(`\r resolved ${out.size} transitives, queue=${queue.length - head} `);
|
|
393
400
|
}
|
|
394
401
|
});
|
|
395
402
|
await Promise.all(workers);
|
|
396
|
-
if (
|
|
403
|
+
if (process.stdout.isTTY) process.stdout.write(`\r resolved ${out.size} transitives \n`);
|
|
404
|
+
else if (out.size) console.log(` resolved ${out.size} transitives`);
|
|
397
405
|
|
|
398
406
|
return out;
|
|
399
407
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Fucking Autonomous Dependency Checker — multi-ecosystem CVE / EOL / outdated / vendored-JS scanner for Maven, npm and Yarn monorepos. Self-contained HTML + Word report.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "N8tz <n8tz.js@gmail.com>",
|
package/test/cpe.test.js
CHANGED
|
@@ -51,6 +51,24 @@ test("matchVersionRange honours hard-pinned criteria version", () => {
|
|
|
51
51
|
assert.equal(matchVersionRange("2.14.1", m), false);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
test("matchVersionRange folds CPE update qualifier into the version pin (H5)", () => {
|
|
55
|
+
// CPE 2.3 ":1.0.0:rc1:" describes the 1.0.0-rc1 pre-release. A release dep
|
|
56
|
+
// at 1.0.0 must NOT match — that was the H5 cascade.
|
|
57
|
+
const beta = { criteria: "cpe:2.3:a:apache:foo:1.0.0:beta1:*:*:*:*:*:*", vulnerable: true };
|
|
58
|
+
assert.equal(matchVersionRange("1.0.0", beta), false);
|
|
59
|
+
assert.equal(matchVersionRange("1.0.0-beta1", beta), true);
|
|
60
|
+
|
|
61
|
+
const rc = { criteria: "cpe:2.3:a:apache:foo:1.0.0:rc1:*:*:*:*:*:*", vulnerable: true };
|
|
62
|
+
assert.equal(matchVersionRange("1.0.0", rc), false);
|
|
63
|
+
assert.equal(matchVersionRange("1.0.0-rc1", rc), true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("cpeMatchesDep rejects short-token vendor leak (M4 mirror of H2)", () => {
|
|
67
|
+
const cpe = parseCpe23("cpe:2.3:a:a:log4j-core:*:*:*:*:*:*:*:*");
|
|
68
|
+
const dep = { groupId: "com.unrelated.log4j-core", artifactId: "log4j-core", ecosystem: "maven" };
|
|
69
|
+
assert.equal(cpeMatchesDep(cpe, dep), false);
|
|
70
|
+
});
|
|
71
|
+
|
|
54
72
|
test("matchVersionRange returns true for unknown dep version (conservative)", () => {
|
|
55
73
|
const m = { criteria: "cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*", vulnerable: true, versionEndExcluding: "2.15.0" };
|
|
56
74
|
assert.equal(matchVersionRange(null, m), true);
|
package/test/cve-match.test.js
CHANGED
|
@@ -48,6 +48,62 @@ test("vendorMatchesGroup heuristic", () => {
|
|
|
48
48
|
assert.equal(vendorMatchesGroup("springframework", "org.springframework"), true);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
test("vendorMatchesGroup rejects substring leaks (H2)", () => {
|
|
52
|
+
// All these cases used to leak through unbounded `g.includes(v)` / `v.includes(g)`
|
|
53
|
+
// branches. The fix keeps only equality + dot-segment membership.
|
|
54
|
+
assert.equal(vendorMatchesGroup("a", "com.acme.client"), false);
|
|
55
|
+
assert.equal(vendorMatchesGroup("ibm", "com.ibmcloudant.driver"), false);
|
|
56
|
+
assert.equal(vendorMatchesGroup("go", "org.golang.x"), false);
|
|
57
|
+
// "open" used to wrongly match every org.opensaml.* groupId via substring
|
|
58
|
+
assert.equal(vendorMatchesGroup("open", "org.opensaml.core"), false);
|
|
59
|
+
// Dot-segment match still works for legitimate cases
|
|
60
|
+
assert.equal(vendorMatchesGroup("apache", "org.apache.commons"), true);
|
|
61
|
+
assert.equal(vendorMatchesGroup("springframework", "org.springframework.boot"), true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("matchDepsAgainstCves hides 'possible' tier by default (H3)", () => {
|
|
65
|
+
// product matches `log4j-core` but vendor `acme` doesn't match groupId.
|
|
66
|
+
// Without opts.includePossibleTier we expect zero matches.
|
|
67
|
+
const idx = {
|
|
68
|
+
byPackageName: {},
|
|
69
|
+
byProduct: {
|
|
70
|
+
"log4j-core": [
|
|
71
|
+
{ id: "CVE-FAKE-0001", severity: "HIGH", vendor: "acme", product: "log4j-core",
|
|
72
|
+
ranges: [{ version: "2.0", lessThan: "2.20.0", status: "affected" }] },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const deps = new Map([
|
|
77
|
+
["org.apache.logging.log4j:log4j-core", {
|
|
78
|
+
groupId: "org.apache.logging.log4j", artifactId: "log4j-core", version: "2.14.0", scope: "compile", pomPaths: [],
|
|
79
|
+
}],
|
|
80
|
+
]);
|
|
81
|
+
assert.equal(matchDepsAgainstCves(deps, idx).length, 0);
|
|
82
|
+
assert.equal(matchDepsAgainstCves(deps, idx, { includePossibleTier: true }).length, 1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("matchDepsAgainstCves does not flag deps when range has no bounds (H1+H3)", () => {
|
|
86
|
+
// Sparse range entry (`{status:"affected"}` with no bounds) used to flag
|
|
87
|
+
// every version that hit the product bucket. Combined with H3 hiding the
|
|
88
|
+
// possible tier, this should now produce zero matches even when the
|
|
89
|
+
// vendor matches.
|
|
90
|
+
const idx = {
|
|
91
|
+
byPackageName: {},
|
|
92
|
+
byProduct: {
|
|
93
|
+
"log4j-core": [
|
|
94
|
+
{ id: "CVE-FAKE-0002", severity: "HIGH", vendor: "apache", product: "log4j-core",
|
|
95
|
+
ranges: [{ status: "affected" }] },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const deps = new Map([
|
|
100
|
+
["org.apache.logging.log4j:log4j-core", {
|
|
101
|
+
groupId: "org.apache.logging.log4j", artifactId: "log4j-core", version: "2.99.0", scope: "compile", pomPaths: [],
|
|
102
|
+
}],
|
|
103
|
+
]);
|
|
104
|
+
assert.equal(matchDepsAgainstCves(deps, idx).length, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
51
107
|
test("collectResolvedDeps dedupes by g:a and includes external parent POMs", async () => {
|
|
52
108
|
const { store, props } = await pipeline(COMPLEX);
|
|
53
109
|
const deps = collectResolvedDeps(store, props, {});
|
|
@@ -41,6 +41,15 @@ test("isVersionAffected returns false when status != affected", () => {
|
|
|
41
41
|
assert.equal(isVersionAffected("1.5", spec), false);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
test("isVersionAffected fail-closed when spec has no version bounds (H1)", () => {
|
|
45
|
+
// CVEProject sometimes emits {status:"affected"} stubs with no version
|
|
46
|
+
// fields. The matcher must NOT fall through to `return true` — that was
|
|
47
|
+
// the H1 cascade.
|
|
48
|
+
assert.equal(isVersionAffected("2.14.0", { status: "affected" }), false);
|
|
49
|
+
assert.equal(isVersionAffected("2.14.0", {}), false);
|
|
50
|
+
assert.equal(isVersionAffected("0.0.1", { status: "affected" }), false);
|
|
51
|
+
});
|
|
52
|
+
|
|
44
53
|
test("parseRange handles Maven range syntax", () => {
|
|
45
54
|
assert.deepEqual(parseRange("1.2.3"), { exact: "1.2.3" });
|
|
46
55
|
assert.deepEqual(parseRange("[1.0,2.0)"), { lower: "1.0", lowerInclusive: true, upper: "2.0", upperInclusive: false });
|