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.
@@ -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
- const prodMatches = cveMatches.filter(m => !m.dep?.isDev);
545
- const devMatches = cveMatches.filter(m => m.dep?.isDev);
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 = parseCpe23(cpeMatch.criteria || "");
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
- try { return compareMavenVersions(depVersion, parsed.version) === 0; }
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 = parseCpe23(m.criteria || "");
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 = parseCpe23(m.criteria || "");
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
- if (g.includes(v) || v.includes(g)) return true;
170
- // org.apache.commons matches vendor "apache"
171
- const parts = g.split(".");
172
- return parts.includes(v);
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/3: product match, scoped by vendor heuristic
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
  }
@@ -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 { verbose, offline } = opts;
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 delay = getRateDelay();
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
- let i = 0;
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
- const data = await fetchOne(cveId, opts);
197
- byId.set(cveId, data);
198
- i++;
199
- if (verbose && i % 5 === 0) process.stdout.write(`\r NVD: ${i} fetched`);
200
- // Rate limit between requests
201
- await sleep(delay);
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 (verbose && i) process.stdout.write(`\r NVD: ${i} fetched \n`);
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
- if (verbose) process.stdout.write(`\r OSV batch ${++batchIdx}/${batches.length} (${batch.length} deps)…`);
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 (verbose) process.stdout.write(`\r OSV batches complete (${batches.length}) \n`);
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
- let fetched = 0;
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
- try {
225
- const r = await fetcher(`${OSV_BASE}/v1/vulns/${encodeURIComponent(id)}`, {
226
- headers: { "User-Agent": "fad-checker-osv" },
227
- });
228
- if (r.ok) {
229
- const body = await r.json();
230
- detailById.set(id, body);
231
- writeCache(detailCacheKey, body);
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
- } catch { /* ignore individual failures */ }
234
- fetched++;
235
- if (verbose && fetched % 25 === 0) process.stdout.write(`\r OSV details fetched: ${fetched}/${allIds.size}`);
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 only emit a minimal match with no description so the
252
- // report still surfaces it.
253
- matches.push({
254
- dep,
255
- cve: { id, severity: "UNKNOWN", score: null, description: "", fixVersion: null },
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
- // Simple p-limit style throttle without requiring p-limit here (already used in fad-checker.js)
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.shift();
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
- if (verbose) process.stdout.write(`\r resolved ${out.size} transitives, queue=${queue.length}`);
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 (verbose) process.stdout.write(`\r resolved ${out.size} transitives \n`);
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.1",
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);
@@ -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 });