fad-checker 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +21 -21
- package/CRITICAL-REVIEW.md +343 -0
- package/README.md +37 -37
- package/completions/{fad-check.bash → fad-checker.bash} +3 -3
- package/completions/{fad-check.zsh → fad-checker.zsh} +2 -2
- package/docs/ARCHITECTURE.md +10 -10
- package/docs/USAGE.md +35 -35
- package/{fad-check.js → fad-checker.js} +25 -21
- package/lib/cache-archive.js +9 -9
- package/lib/config.js +3 -3
- package/lib/cpe.js +23 -5
- package/lib/cve-download.js +3 -3
- package/lib/cve-match.js +13 -7
- package/lib/cve-report.js +4 -4
- package/lib/maven-repo.js +3 -3
- package/lib/maven-version.js +6 -0
- package/lib/nvd.js +62 -17
- package/lib/osv.js +53 -28
- package/lib/outdated.js +25 -5
- package/lib/retire.js +5 -5
- package/lib/scan-completeness.js +2 -2
- package/lib/snyk.js +4 -4
- package/lib/transitive.js +15 -7
- package/package.json +6 -6
- package/test/core.test.js +4 -4
- package/test/cpe.test.js +18 -0
- package/test/cve-match.test.js +56 -0
- package/test/cve-report.test.js +1 -1
- package/test/maven-version.test.js +9 -0
- package/test/monorepo.test.js +1 -1
- package/test/snyk.test.js +1 -1
- package/test/transitive.test.js +2 -2
package/lib/cve-report.js
CHANGED
|
@@ -1246,11 +1246,11 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
|
|
|
1246
1246
|
|
|
1247
1247
|
return `
|
|
1248
1248
|
<header class="report-header">
|
|
1249
|
-
<h1>FAD-
|
|
1249
|
+
<h1>FAD-Checker Report</h1>
|
|
1250
1250
|
<div class="report-subtitle">Multi-ecosystem dependency security audit</div>
|
|
1251
1251
|
<div class="meta">
|
|
1252
1252
|
Project: <strong>${esc(projectInfo.name)}</strong> · <code class="path">${esc(projectInfo.src)}</code><br>
|
|
1253
|
-
Generated: ${esc(projectInfo.generatedAt)}${projectInfo.toolVersion ? ` · fad-
|
|
1253
|
+
Generated: ${esc(projectInfo.generatedAt)}${projectInfo.toolVersion ? ` · fad-checker ${esc(projectInfo.toolVersion)}` : ""}${projectInfo.cveDataDate ? ` · CVE data: ${esc(projectInfo.cveDataDate)}` : ""}
|
|
1254
1254
|
</div>
|
|
1255
1255
|
</header>
|
|
1256
1256
|
${exec}
|
|
@@ -1401,7 +1401,7 @@ const TOGGLE_SCRIPT = `
|
|
|
1401
1401
|
|
|
1402
1402
|
function generateHtmlReport(payload) {
|
|
1403
1403
|
return `<!doctype html>
|
|
1404
|
-
<html><head><meta charset="utf-8"><title>FAD-
|
|
1404
|
+
<html><head><meta charset="utf-8"><title>FAD-Checker Report</title><style>${CSS}</style></head>
|
|
1405
1405
|
<body>${buildBody(payload)}${TOGGLE_SCRIPT}</body></html>`;
|
|
1406
1406
|
}
|
|
1407
1407
|
|
|
@@ -1426,7 +1426,7 @@ function generateWordReport(payload) {
|
|
|
1426
1426
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
|
1427
1427
|
<meta name="ProgId" content="Word.Document">
|
|
1428
1428
|
<meta name="Generator" content="Microsoft Word 15">
|
|
1429
|
-
<title>FAD-
|
|
1429
|
+
<title>FAD-Checker Report</title>
|
|
1430
1430
|
<style>${CSS}
|
|
1431
1431
|
/* In Word: keep every detail row expanded since scripts are disabled */
|
|
1432
1432
|
tr.detail-row { display: table-row !important; }
|
package/lib/maven-repo.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Reading <maven-metadata.xml> for latest-version discovery
|
|
9
9
|
* (lib/outdated.js)
|
|
10
10
|
*
|
|
11
|
-
* Repository entry shape (from ~/.fad-
|
|
11
|
+
* Repository entry shape (from ~/.fad-checker/config.json or CLI):
|
|
12
12
|
* { name?, url, auth? } auth = "user:pass" (we wrap as Basic <base64>)
|
|
13
13
|
*
|
|
14
14
|
* URL convention: each repo URL must end at the directory under which Maven
|
|
@@ -76,11 +76,11 @@ function authHeader(auth) {
|
|
|
76
76
|
* fetcher custom fetch (for tests)
|
|
77
77
|
* readBody read response.text() into body (default false to save mem
|
|
78
78
|
* on HEAD calls; transitive.js sets true for POMs)
|
|
79
|
-
* userAgent default "fad-
|
|
79
|
+
* userAgent default "fad-checker-maven-repo"
|
|
80
80
|
* onMiss callback(repo, status) for telemetry (verbose mode)
|
|
81
81
|
*/
|
|
82
82
|
async function tryRepos(repos, pathSuffix, opts = {}) {
|
|
83
|
-
const { method = "GET", fetcher = globalThis.fetch, readBody = false, userAgent = "fad-
|
|
83
|
+
const { method = "GET", fetcher = globalThis.fetch, readBody = false, userAgent = "fad-checker-maven-repo", onMiss } = opts;
|
|
84
84
|
for (const repo of repos) {
|
|
85
85
|
const url = repo.url + pathSuffix.replace(/^\//, "");
|
|
86
86
|
const headers = { "User-Agent": userAgent };
|
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
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
* API: https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-YYYY-NNNN
|
|
10
10
|
* Rate limit: 5 req / 30s unauthenticated, 50 req / 30s with NVD_API_KEY env var.
|
|
11
11
|
*
|
|
12
|
-
* Cache: ~/.fad-
|
|
12
|
+
* Cache: ~/.fad-checker/nvd-cache/<cve-id>.json, 7-day TTL.
|
|
13
13
|
*/
|
|
14
14
|
const fs = require("fs");
|
|
15
15
|
const path = require("path");
|
|
16
16
|
const os = require("os");
|
|
17
17
|
const { getNvdApiKey } = require("./config");
|
|
18
18
|
|
|
19
|
-
const NVD_CACHE_DIR = path.join(os.homedir(), ".fad-
|
|
19
|
+
const NVD_CACHE_DIR = path.join(os.homedir(), ".fad-checker", "nvd-cache");
|
|
20
20
|
const NVD_CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
21
21
|
const NVD_BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0";
|
|
22
22
|
|
|
@@ -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
|
}
|
|
@@ -146,7 +155,7 @@ async function fetchOne(cveId, opts = {}) {
|
|
|
146
155
|
const cached = readCache(cveId);
|
|
147
156
|
if (cached !== null && cached !== undefined) return cached;
|
|
148
157
|
if (offline) return null;
|
|
149
|
-
const headers = { "User-Agent": "fad-
|
|
158
|
+
const headers = { "User-Agent": "fad-checker-nvd-enrich" };
|
|
150
159
|
const key = getNvdApiKey();
|
|
151
160
|
if (key) headers["apiKey"] = key;
|
|
152
161
|
const url = `${NVD_BASE}?cveId=${encodeURIComponent(cveId)}`;
|
|
@@ -169,43 +178,79 @@ async function fetchOne(cveId, opts = {}) {
|
|
|
169
178
|
}
|
|
170
179
|
|
|
171
180
|
/**
|
|
172
|
-
* Enrich an array of fad-
|
|
181
|
+
* Enrich an array of fad-checker matches in place by fetching their NVD records.
|
|
173
182
|
* Adds: cve.description (replaced by NVD's), cve.cvssVector, cve.cvssVersion,
|
|
174
183
|
* cve.references, cve.cpes. Severity/score are only overwritten if currently UNKNOWN/null.
|
|
175
184
|
*
|
|
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);
|
|
207
252
|
if (!data) continue;
|
|
208
|
-
// Merge: prefer NVD's official text + CVSS but keep what fad-
|
|
253
|
+
// Merge: prefer NVD's official text + CVSS but keep what fad-checker/OSV already has
|
|
209
254
|
// NVD's description is the official long form — prefer it when available.
|
|
210
255
|
if (data.description) {
|
|
211
256
|
m.cve.description = data.description.length > 2000
|
package/lib/osv.js
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
* POST /v1/query for a single dep
|
|
12
12
|
* GET /v1/vulns/{id} to fetch full details
|
|
13
13
|
*
|
|
14
|
-
* Cached responses live in ~/.fad-
|
|
14
|
+
* Cached responses live in ~/.fad-checker/osv-cache/ for 12h.
|
|
15
15
|
*/
|
|
16
16
|
const fs = require("fs");
|
|
17
17
|
const path = require("path");
|
|
18
18
|
const os = require("os");
|
|
19
19
|
|
|
20
|
-
const OSV_CACHE_DIR = path.join(os.homedir(), ".fad-
|
|
20
|
+
const OSV_CACHE_DIR = path.join(os.homedir(), ".fad-checker", "osv-cache");
|
|
21
21
|
const OSV_CACHE_TTL_MS = 12 * 3600 * 1000;
|
|
22
22
|
const OSV_BASE = "https://api.osv.dev";
|
|
23
23
|
const BATCH_SIZE = 800; // OSV limit is 1000; stay under for safety
|
|
@@ -101,7 +101,7 @@ function osvPkgName(dep) {
|
|
|
101
101
|
return dep.ecosystem === "npm" ? dep.artifactId : `${dep.groupId}:${dep.artifactId}`;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
/** Convert one OSV vuln to fad-
|
|
104
|
+
/** Convert one OSV vuln to fad-checker match shape. */
|
|
105
105
|
function vulnToMatch(dep, vuln) {
|
|
106
106
|
const id = pickPrimaryId(vuln);
|
|
107
107
|
const { severity, score } = severityFromOsv(vuln);
|
|
@@ -177,10 +177,12 @@ 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
|
-
headers: { "Content-Type": "application/json", "User-Agent": "fad-
|
|
185
|
+
headers: { "Content-Type": "application/json", "User-Agent": "fad-checker-osv" },
|
|
184
186
|
body: JSON.stringify({ queries: batch }),
|
|
185
187
|
});
|
|
186
188
|
if (!res.ok) {
|
|
@@ -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));
|
|
@@ -265,7 +290,7 @@ function runMatches(deps, indexMap, detailById) {
|
|
|
265
290
|
}
|
|
266
291
|
|
|
267
292
|
/**
|
|
268
|
-
* Public: query OSV for every dep in `resolvedDeps` Map, return fad-
|
|
293
|
+
* Public: query OSV for every dep in `resolvedDeps` Map, return fad-checker-shape matches.
|
|
269
294
|
*/
|
|
270
295
|
async function queryOsvForDeps(resolvedDeps, opts = {}) {
|
|
271
296
|
const deps = [];
|
package/lib/outdated.js
CHANGED
|
@@ -13,7 +13,7 @@ const { compareMavenVersions } = require("./maven-version");
|
|
|
13
13
|
const KNOWN_OBSOLETE = require("../data/known-obsolete.json");
|
|
14
14
|
const EOL_MAPPING = require("../data/eol-mapping.json");
|
|
15
15
|
|
|
16
|
-
const CACHE_DIR = path.join(os.homedir(), ".fad-
|
|
16
|
+
const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
|
|
17
17
|
const EOL_CACHE_PATH = path.join(CACHE_DIR, "eol-cache.json");
|
|
18
18
|
const VERSION_CACHE_PATH = path.join(CACHE_DIR, "version-cache.json");
|
|
19
19
|
const EOL_CACHE_MAX_AGE_MS = 7 * 24 * 3600 * 1000; // 7 days
|
|
@@ -58,7 +58,7 @@ async function fetchEndoflife(product, cache, opts = {}) {
|
|
|
58
58
|
if (opts.offline) return null;
|
|
59
59
|
try {
|
|
60
60
|
const res = await fetch(`https://endoflife.date/api/${encodeURIComponent(product)}.json`, {
|
|
61
|
-
headers: { "User-Agent": "fad-
|
|
61
|
+
headers: { "User-Agent": "fad-checker-eol-checker" },
|
|
62
62
|
});
|
|
63
63
|
if (!res.ok) {
|
|
64
64
|
cache.entries[product] = { error: `HTTP ${res.status}` };
|
|
@@ -174,7 +174,7 @@ async function fetchLatestVersion(groupId, artifactId, cache, opts = {}) {
|
|
|
174
174
|
// internal Nexus/Artifactory artifacts that aren't on Central.
|
|
175
175
|
try {
|
|
176
176
|
const url = `https://search.maven.org/solrsearch/select?q=g:%22${encodeURIComponent(groupId)}%22+AND+a:%22${encodeURIComponent(artifactId)}%22&core=gav&rows=1&wt=json`;
|
|
177
|
-
const res = await fetch(url, { headers: { "User-Agent": "fad-
|
|
177
|
+
const res = await fetch(url, { headers: { "User-Agent": "fad-checker-outdated-checker" } });
|
|
178
178
|
if (res.ok) {
|
|
179
179
|
const json = await res.json();
|
|
180
180
|
const doc = json?.response?.docs?.[0];
|
|
@@ -189,7 +189,7 @@ async function fetchLatestVersion(groupId, artifactId, cache, opts = {}) {
|
|
|
189
189
|
if (Array.isArray(opts.repos) && opts.repos.length) {
|
|
190
190
|
try {
|
|
191
191
|
const { fetchMavenMetadata } = require("./maven-repo");
|
|
192
|
-
const hit = await fetchMavenMetadata(opts.repos, groupId, artifactId, { userAgent: "fad-
|
|
192
|
+
const hit = await fetchMavenMetadata(opts.repos, groupId, artifactId, { userAgent: "fad-checker-outdated-checker" });
|
|
193
193
|
if (hit?.body) {
|
|
194
194
|
const latest = parseMavenMetadataLatest(hit.body);
|
|
195
195
|
if (latest) {
|
|
@@ -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/retire.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* unmanaged .js / .min.js files (no package-lock to back them).
|
|
5
5
|
*
|
|
6
6
|
* retire ships its own signature DB updated weekly; we just shell
|
|
7
|
-
* out to it and normalise the output to fad-
|
|
7
|
+
* out to it and normalise the output to fad-checker match shape so the
|
|
8
8
|
* report can render it like any other CVE source.
|
|
9
9
|
*
|
|
10
|
-
* Cache: ~/.fad-
|
|
10
|
+
* Cache: ~/.fad-checker/retire-cache/<md5(src)>.json, 24 h TTL.
|
|
11
11
|
*
|
|
12
12
|
* The CLI is expected at node_modules/.bin/retire (declared in
|
|
13
13
|
* package.json deps). When bundled with bun, we also try `retire`
|
|
@@ -21,7 +21,7 @@ const { execFile } = require("child_process");
|
|
|
21
21
|
const { promisify } = require("util");
|
|
22
22
|
const execFileP = promisify(execFile);
|
|
23
23
|
|
|
24
|
-
const RETIRE_CACHE_DIR = path.join(os.homedir(), ".fad-
|
|
24
|
+
const RETIRE_CACHE_DIR = path.join(os.homedir(), ".fad-checker", "retire-cache");
|
|
25
25
|
const RETIRE_CACHE_TTL_MS = 24 * 3600 * 1000;
|
|
26
26
|
|
|
27
27
|
function cacheKey(srcDir) {
|
|
@@ -82,7 +82,7 @@ async function runRetire(srcDir, opts = {}) {
|
|
|
82
82
|
|
|
83
83
|
// retire.js refuses to write to /dev/stdout, so we use a real temp file
|
|
84
84
|
// and read it back. Falls back to stdout if --outputpath is rejected.
|
|
85
|
-
const tmpOut = path.join(os.tmpdir(), `fad-
|
|
85
|
+
const tmpOut = path.join(os.tmpdir(), `fad-checker-retire-${process.pid}-${Date.now()}.json`);
|
|
86
86
|
const args = [
|
|
87
87
|
"--outputformat", "json",
|
|
88
88
|
"--outputpath", tmpOut,
|
|
@@ -119,7 +119,7 @@ async function runRetire(srcDir, opts = {}) {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
|
-
* Normalise a retire.js result tree to fad-
|
|
122
|
+
* Normalise a retire.js result tree to fad-checker match shape.
|
|
123
123
|
*
|
|
124
124
|
* retire output (jsonsimple-ish):
|
|
125
125
|
* {
|
package/lib/scan-completeness.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/scan-completeness.js — produce warnings telling the user when fad-
|
|
2
|
+
* lib/scan-completeness.js — produce warnings telling the user when fad-checker
|
|
3
3
|
* has gone as far as it can without running the real build tool.
|
|
4
4
|
*
|
|
5
5
|
* The two big "you need a real scan" cases:
|
|
@@ -57,7 +57,7 @@ function detectScanCompletenessWarnings(resolvedDeps, opts = {}) {
|
|
|
57
57
|
type: "unresolved-versions",
|
|
58
58
|
count: items.length,
|
|
59
59
|
items,
|
|
60
|
-
message: `${items.length} Maven dep(s) without a concrete version — silently skipped from CVE/OSV/outdated scans. Their versions are likely pinned by an external BOM or a property defined outside the source tree. Run "mvn dependency:tree" against the source (or "snyk test --all-projects" against the cleaned POMs${opts.ranSnyk ? " — already done with --snyk" : ", or re-run fad-
|
|
60
|
+
message: `${items.length} Maven dep(s) without a concrete version — silently skipped from CVE/OSV/outdated scans. Their versions are likely pinned by an external BOM or a property defined outside the source tree. Run "mvn dependency:tree" against the source (or "snyk test --all-projects" against the cleaned POMs${opts.ranSnyk ? " — already done with --snyk" : ", or re-run fad-checker with --snyk"}) to resolve them and complete the scan.`,
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
|
package/lib/snyk.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* lib/snyk.js — optional Snyk integration.
|
|
3
3
|
*
|
|
4
4
|
* Runs `snyk test --all-projects --json` on the cleaned POM directory and
|
|
5
|
-
* normalises the output to fad-
|
|
5
|
+
* normalises the output to fad-checker's CVE match shape so the report can
|
|
6
6
|
* merge findings from both engines.
|
|
7
7
|
*/
|
|
8
8
|
const { execFile } = require("child_process");
|
|
@@ -53,7 +53,7 @@ function parseSnykStdout(stdout) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Normalise snyk JSON to fad-
|
|
56
|
+
* Normalise snyk JSON to fad-checker match objects:
|
|
57
57
|
* [{ dep: {groupId, artifactId, version}, cve: {id, severity, score, ...}, source: 'snyk' }]
|
|
58
58
|
*/
|
|
59
59
|
function parseSnykResults(snykProjectsJson) {
|
|
@@ -91,8 +91,8 @@ function parseSnykResults(snykProjectsJson) {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
* Merge fad-
|
|
95
|
-
* When a finding exists in both, fad-
|
|
94
|
+
* Merge fad-checker and Snyk matches, deduping by (groupId:artifactId, cve.id).
|
|
95
|
+
* When a finding exists in both, fad-checker's row is kept but tagged source='both'.
|
|
96
96
|
*/
|
|
97
97
|
function mergeWithFadResults(fadMatches, snykMatches) {
|
|
98
98
|
const byKey = new Map();
|
package/lib/transitive.js
CHANGED
|
@@ -24,7 +24,7 @@ const path = require("path");
|
|
|
24
24
|
const os = require("os");
|
|
25
25
|
const { parseStringPromise } = require("xml2js");
|
|
26
26
|
|
|
27
|
-
const POM_CACHE_DIR = path.join(os.homedir(), ".fad-
|
|
27
|
+
const POM_CACHE_DIR = path.join(os.homedir(), ".fad-checker", "poms-cache");
|
|
28
28
|
const MAVEN_CENTRAL = "https://repo1.maven.org/maven2";
|
|
29
29
|
|
|
30
30
|
// Maven's scope-propagation matrix (rows: direct dep scope, cols: transitive scope)
|
|
@@ -62,7 +62,7 @@ async function fetchPom(g, a, v, opts = {}) {
|
|
|
62
62
|
if (Array.isArray(repos)) {
|
|
63
63
|
try {
|
|
64
64
|
const { fetchPomFromRepos } = require("./maven-repo");
|
|
65
|
-
const hit = await fetchPomFromRepos(repos, g, a, v, { fetcher, userAgent: "fad-
|
|
65
|
+
const hit = await fetchPomFromRepos(repos, g, a, v, { fetcher, userAgent: "fad-checker-transitive" });
|
|
66
66
|
if (hit?.body) {
|
|
67
67
|
await fs.promises.mkdir(cacheDir, { recursive: true });
|
|
68
68
|
await fs.promises.writeFile(cf, hit.body);
|
|
@@ -79,7 +79,7 @@ async function fetchPom(g, a, v, opts = {}) {
|
|
|
79
79
|
}
|
|
80
80
|
const url = pomPath(g, a, v);
|
|
81
81
|
try {
|
|
82
|
-
const res = await fetcher(url, { headers: { "User-Agent": "fad-
|
|
82
|
+
const res = await fetcher(url, { headers: { "User-Agent": "fad-checker-transitive" } });
|
|
83
83
|
if (res.status === 404) {
|
|
84
84
|
await fs.promises.mkdir(cacheDir, { recursive: true });
|
|
85
85
|
await fs.promises.writeFile(cf, "__NOT_FOUND__");
|
|
@@ -286,6 +286,10 @@ async function resolveTransitiveDeps(directDeps, opts = {}) {
|
|
|
286
286
|
|
|
287
287
|
const visited = new Map(); // g:a -> { ...entry, depth }
|
|
288
288
|
const queue = [];
|
|
289
|
+
// Index cursor instead of `queue.shift()` — shift() is O(n) per call which
|
|
290
|
+
// turns the BFS into O(n²) on large dep trees. The cursor is safe across
|
|
291
|
+
// concurrent workers because `head++` is atomic in single-threaded JS.
|
|
292
|
+
let head = 0;
|
|
289
293
|
|
|
290
294
|
// Seed: every direct dep (we won't return them, but we walk their children).
|
|
291
295
|
for (const dep of directDeps) {
|
|
@@ -310,8 +314,8 @@ async function resolveTransitiveDeps(directDeps, opts = {}) {
|
|
|
310
314
|
|
|
311
315
|
// Worker pool
|
|
312
316
|
const workers = Array.from({ length: concurrency }, async () => {
|
|
313
|
-
while (queue.length) {
|
|
314
|
-
const node = queue
|
|
317
|
+
while (head < queue.length) {
|
|
318
|
+
const node = queue[head++];
|
|
315
319
|
if (!node) break;
|
|
316
320
|
if (node.depth >= maxDepth) continue;
|
|
317
321
|
|
|
@@ -389,11 +393,15 @@ async function resolveTransitiveDeps(directDeps, opts = {}) {
|
|
|
389
393
|
rootExclusions: [...(node.rootExclusions || []), ...(dep.exclusions || [])],
|
|
390
394
|
});
|
|
391
395
|
}
|
|
392
|
-
|
|
396
|
+
// Progress visible whenever stdout is a TTY — transitive resolution
|
|
397
|
+
// dominates wall-clock time on first run and used to look like a hang.
|
|
398
|
+
// On non-TTY (pipes, CI, tests) we skip the \r-overwrite spam.
|
|
399
|
+
if (process.stdout.isTTY) process.stdout.write(`\r resolved ${out.size} transitives, queue=${queue.length - head} `);
|
|
393
400
|
}
|
|
394
401
|
});
|
|
395
402
|
await Promise.all(workers);
|
|
396
|
-
if (
|
|
403
|
+
if (process.stdout.isTTY) process.stdout.write(`\r resolved ${out.size} transitives \n`);
|
|
404
|
+
else if (out.size) console.log(` resolved ${out.size} transitives`);
|
|
397
405
|
|
|
398
406
|
return out;
|
|
399
407
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Fucking Autonomous Dependency Checker — multi-ecosystem CVE / EOL / outdated / vendored-JS scanner for Maven, npm and Yarn monorepos. Self-contained HTML + Word report.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "N8tz <n8tz.js@gmail.com>",
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
"N8tz (https://github.com/N8tz)"
|
|
12
12
|
],
|
|
13
13
|
"bin": {
|
|
14
|
-
"fad-checker": "fad-
|
|
15
|
-
"fad": "fad-
|
|
14
|
+
"fad-checker": "fad-checker.js",
|
|
15
|
+
"fad": "fad-checker.js"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
18
|
"test": "node --test test/*.test.js",
|
|
19
|
-
"build:linux": "bun build fad-
|
|
20
|
-
"build:win": "bun build fad-
|
|
21
|
-
"build:macos": "bun build fad-
|
|
19
|
+
"build:linux": "bun build fad-checker.js --compile --target=bun-linux-x64 --outfile=dist/fad-checker-linux",
|
|
20
|
+
"build:win": "bun build fad-checker.js --compile --target=bun-windows-x64 --outfile=dist/fad-checker.exe",
|
|
21
|
+
"build:macos": "bun build fad-checker.js --compile --target=bun-darwin-x64 --outfile=dist/fad-checker-macos",
|
|
22
22
|
"build": "npm run build:linux && npm run build:win"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
package/test/core.test.js
CHANGED
|
@@ -22,7 +22,7 @@ async function pipeline(src, { deps2Exclude } = {}) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
test("findPomFiles skips target/.git/node_modules", () => {
|
|
25
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-
|
|
25
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-checker-test-"));
|
|
26
26
|
fs.mkdirSync(path.join(tmp, "target"));
|
|
27
27
|
fs.writeFileSync(path.join(tmp, "target", "pom.xml"), "<project/>");
|
|
28
28
|
fs.writeFileSync(path.join(tmp, "pom.xml"), "<project/>");
|
|
@@ -93,7 +93,7 @@ test("BOM import (scope=import) pulls in managed deps from local BOM", async ()
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
test("rewritePoms writes a clean tree in --target mode, target ≠ src", async () => {
|
|
96
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-
|
|
96
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-checker-target-"));
|
|
97
97
|
const { store, props, pomFiles } = await pipeline(COMPLEX);
|
|
98
98
|
const opts = {
|
|
99
99
|
srcRoot: COMPLEX, targetRoot: tmp,
|
|
@@ -125,7 +125,7 @@ test("rewritePoms in --test (readOnly) mode does not crash with undefined target
|
|
|
125
125
|
|
|
126
126
|
test("missing external parent is flagged in missingById", async () => {
|
|
127
127
|
const { store, props, pomFiles } = await pipeline(PRIVATE_FIX);
|
|
128
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-
|
|
128
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-checker-priv-"));
|
|
129
129
|
const opts = {
|
|
130
130
|
srcRoot: PRIVATE_FIX, targetRoot: tmp,
|
|
131
131
|
deps2Exclude: /^(com\.client\.private|org\.megacorp)/,
|
|
@@ -143,7 +143,7 @@ test("missing external parent is flagged in missingById", async () => {
|
|
|
143
143
|
|
|
144
144
|
test("parent version in rewritten POM uses parent's version, not child's", async () => {
|
|
145
145
|
// Simple has child app with no own <version>; the rewritten parent ref should be 1.0.0
|
|
146
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-
|
|
146
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-checker-pv-"));
|
|
147
147
|
const { store, props, pomFiles } = await pipeline(SIMPLE);
|
|
148
148
|
const opts = { srcRoot: SIMPLE, targetRoot: tmp, deps2Exclude: null, verbose: false, readOnly: false };
|
|
149
149
|
for (const f of pomFiles) await core.rewritePoms(f, store, props, opts);
|