fad-checker 1.0.1 → 1.0.3

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/fad-checker.js CHANGED
@@ -540,23 +540,30 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
540
540
  }
541
541
 
542
542
  // Split prod vs dev based on the dep's isDev flag (set at collection time
543
- // from Maven scope=test/provided and npm dev/devOptional/optional).
543
+ // from Maven scope=test/provided and npm dev/devOptional/optional). Keep the
544
+ // full per-bucket list (including cpeFiltered) so the HTML report can render
545
+ // its "Likely false positives" appendix — only the CLI headline excludes
546
+ // cpeFiltered to avoid alarming on triaged-out matches.
544
547
  const prodMatches = cveMatches.filter(m => !m.dep?.isDev);
545
548
  const devMatches = cveMatches.filter(m => m.dep?.isDev);
549
+ const prodActive = prodMatches.filter(m => !m.cpeFiltered);
550
+ const devActive = devMatches.filter(m => !m.cpeFiltered);
551
+ const cpeFilteredCount = (prodMatches.length - prodActive.length) + (devMatches.length - devActive.length);
546
552
 
547
- const stats = computeStats(prodMatches);
548
- const devStats = computeStats(devMatches);
549
- console.log(chalk.bold.cyan(`\n 1. CVE Vulnerabilities (production: ${prodMatches.length})`));
553
+ const stats = computeStats(prodActive);
554
+ const devStats = computeStats(devActive);
555
+ console.log(chalk.bold.cyan(`\n 1. CVE Vulnerabilities (production: ${prodActive.length})`));
550
556
  console.log(` critical=${stats.critical} high=${stats.high} medium=${stats.medium} low=${stats.low} unknown=${stats.unknown}`);
551
557
  const depLabel = d => d.ecosystem === "npm" ? `npm:${d.artifactId}` : `${d.groupId}:${d.artifactId}`;
552
- for (const m of prodMatches.slice(0, 20)) {
558
+ for (const m of prodActive.slice(0, 20)) {
553
559
  const sev = (m.cve.severity || "UNKNOWN").padEnd(8);
554
560
  console.log(` ${chalk.red(sev)} ${m.cve.id} ${depLabel(m.dep)}:${m.dep.version}`);
555
561
  }
556
- if (prodMatches.length > 20) console.log(` ... and ${prodMatches.length - 20} more (see report)`);
562
+ if (prodActive.length > 20) console.log(` ... and ${prodActive.length - 20} more (see report)`);
563
+ if (cpeFilteredCount) console.log(chalk.gray(` (${cpeFilteredCount} likely false positives moved to report appendix)`));
557
564
 
558
- if (devMatches.length) {
559
- console.log(chalk.bold.cyan(`\n 2. CVE in dev dependencies (${devMatches.length})`));
565
+ if (devActive.length) {
566
+ console.log(chalk.bold.cyan(`\n 2. CVE in dev dependencies (${devActive.length})`));
560
567
  console.log(` critical=${devStats.critical} high=${devStats.high} medium=${devStats.medium} low=${devStats.low} unknown=${devStats.unknown}`);
561
568
  }
562
569
  if (retireMatches.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,26 @@ 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
+ const segments = g.split(".");
170
+ // Plain dot-segment match (vendor "apache" ⊂ "org.apache.commons").
171
+ if (segments.includes(v)) return true;
172
+ // NVD/CVEProject often record vendor as a legal entity ("qos.ch sarl",
173
+ // "the apache software foundation"). Split on non-alphanumerics and accept
174
+ // the match if any token is a dot-segment of the groupId. Unbounded
175
+ // substring matching is still avoided.
176
+ const vendorTokens = v.split(/[^a-z0-9]+/).filter(Boolean);
177
+ if (vendorTokens.length > 1 && vendorTokens.some(t => segments.includes(t))) return true;
178
+ return false;
173
179
  }
174
180
 
175
- function matchDepsAgainstCves(resolvedDeps, cveIndex) {
181
+ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
176
182
  if (!cveIndex) return [];
177
183
  const byPackage = cveIndex.byPackageName || {};
178
184
  const byProduct = cveIndex.byProduct || {};
185
+ // "possible" tier (product matches but vendor doesn't) is FP-heavy and
186
+ // hidden by default. The CPE refinement step can still rescue a real hit
187
+ // later; opt-in shows the raw bucket for triage.
188
+ const includePossibleTier = !!opts.includePossibleTier;
179
189
 
180
190
  const all = [];
181
191
  for (const dep of resolvedDeps.values()) {
@@ -187,12 +197,12 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex) {
187
197
  // Tier 1: exact packageName match
188
198
  const t1 = matchOne(dep, byPackage[key], "exact");
189
199
  all.push(...t1);
190
- // Tier 2/3: product match, scoped by vendor heuristic
200
+ // Tier 2: product match scoped by vendor heuristic
191
201
  const productMatches = byProduct[dep.artifactId.toLowerCase()] || [];
192
202
  for (const e of productMatches) {
193
203
  if (vendorMatchesGroup(e.vendor, dep.groupId)) {
194
204
  all.push(...matchOne(dep, [e], "probable"));
195
- } else {
205
+ } else if (includePossibleTier) {
196
206
  all.push(...matchOne(dep, [e], "possible"));
197
207
  }
198
208
  }
@@ -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.3",
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,75 @@ 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("vendorMatchesGroup tokenizes multi-word legal-entity vendors", () => {
65
+ // NVD records the logback vendor as "qos.ch sarl" — a legal entity name.
66
+ // Neither equality nor plain dot-segment catches it, but tokenization on
67
+ // non-alphanumerics finds "qos"/"ch" as group segments and confirms the
68
+ // match. Without this, real logback CVEs landed in tier "possible".
69
+ assert.equal(vendorMatchesGroup("qos.ch sarl", "ch.qos.logback"), true);
70
+ assert.equal(vendorMatchesGroup("the apache software foundation", "org.apache.commons"), true);
71
+ // A single-token vendor must NOT be tokenized into substrings — that
72
+ // would re-introduce the H2 leak (e.g. "open" → ["open"] alone never
73
+ // matches arbitrary "org.opensaml.*").
74
+ assert.equal(vendorMatchesGroup("open", "org.opensaml.core"), false);
75
+ });
76
+
77
+ test("matchDepsAgainstCves hides 'possible' tier by default (H3)", () => {
78
+ // product matches `log4j-core` but vendor `acme` doesn't match groupId.
79
+ // Without opts.includePossibleTier we expect zero matches.
80
+ const idx = {
81
+ byPackageName: {},
82
+ byProduct: {
83
+ "log4j-core": [
84
+ { id: "CVE-FAKE-0001", severity: "HIGH", vendor: "acme", product: "log4j-core",
85
+ ranges: [{ version: "2.0", lessThan: "2.20.0", status: "affected" }] },
86
+ ],
87
+ },
88
+ };
89
+ const deps = new Map([
90
+ ["org.apache.logging.log4j:log4j-core", {
91
+ groupId: "org.apache.logging.log4j", artifactId: "log4j-core", version: "2.14.0", scope: "compile", pomPaths: [],
92
+ }],
93
+ ]);
94
+ assert.equal(matchDepsAgainstCves(deps, idx).length, 0);
95
+ assert.equal(matchDepsAgainstCves(deps, idx, { includePossibleTier: true }).length, 1);
96
+ });
97
+
98
+ test("matchDepsAgainstCves does not flag deps when range has no bounds (H1+H3)", () => {
99
+ // Sparse range entry (`{status:"affected"}` with no bounds) used to flag
100
+ // every version that hit the product bucket. Combined with H3 hiding the
101
+ // possible tier, this should now produce zero matches even when the
102
+ // vendor matches.
103
+ const idx = {
104
+ byPackageName: {},
105
+ byProduct: {
106
+ "log4j-core": [
107
+ { id: "CVE-FAKE-0002", severity: "HIGH", vendor: "apache", product: "log4j-core",
108
+ ranges: [{ status: "affected" }] },
109
+ ],
110
+ },
111
+ };
112
+ const deps = new Map([
113
+ ["org.apache.logging.log4j:log4j-core", {
114
+ groupId: "org.apache.logging.log4j", artifactId: "log4j-core", version: "2.99.0", scope: "compile", pomPaths: [],
115
+ }],
116
+ ]);
117
+ assert.equal(matchDepsAgainstCves(deps, idx).length, 0);
118
+ });
119
+
51
120
  test("collectResolvedDeps dedupes by g:a and includes external parent POMs", async () => {
52
121
  const { store, props } = await pipeline(COMPLEX);
53
122
  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 });