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 +15 -8
- package/lib/cpe.js +23 -5
- package/lib/cve-match.js +17 -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 +69 -0
- package/test/maven-version.test.js +9 -0
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(
|
|
548
|
-
const devStats = computeStats(
|
|
549
|
-
console.log(chalk.bold.cyan(`\n 1. CVE Vulnerabilities (production: ${
|
|
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
|
|
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 (
|
|
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 (
|
|
559
|
-
console.log(chalk.bold.cyan(`\n 2. CVE in dev dependencies (${
|
|
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 =
|
|
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,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
|
-
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
}
|
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.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);
|
package/test/cve-match.test.js
CHANGED
|
@@ -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 });
|