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/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-Check Report</h1>
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-check ${esc(projectInfo.toolVersion)}` : ""}${projectInfo.cveDataDate ? ` · CVE data: ${esc(projectInfo.cveDataDate)}` : ""}
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-Check Report</title><style>${CSS}</style></head>
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-Check Report</title>
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-check/config.json or CLI):
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-check-maven-repo"
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-check-maven-repo", onMiss } = opts;
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 };
@@ -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-check/nvd-cache/<cve-id>.json, 7-day TTL.
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-check", "nvd-cache");
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-check-nvd-enrich" };
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-check matches in place by fetching their NVD records.
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 { 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);
207
252
  if (!data) continue;
208
- // Merge: prefer NVD's official text + CVSS but keep what fad-check/OSV already has
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-check/osv-cache/ for 12h.
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-check", "osv-cache");
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-check match shape. */
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
- 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
- headers: { "Content-Type": "application/json", "User-Agent": "fad-check-osv" },
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 (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-check-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));
@@ -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-check-shape matches.
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-check");
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-check-eol-checker" },
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-check-outdated-checker" } });
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-check-outdated-checker" });
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
- // Simple p-limit style throttle without requiring p-limit here (already used in fad-check.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/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-check match shape so the
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-check/retire-cache/<md5(src)>.json, 24 h TTL.
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-check", "retire-cache");
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-check-retire-${process.pid}-${Date.now()}.json`);
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-check match shape.
122
+ * Normalise a retire.js result tree to fad-checker match shape.
123
123
  *
124
124
  * retire output (jsonsimple-ish):
125
125
  * {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * lib/scan-completeness.js — produce warnings telling the user when fad-check
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-check with --snyk"}) to resolve them and complete the scan.`,
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-check's CVE match shape so the report can
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-check match objects:
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-check and Snyk matches, deduping by (groupId:artifactId, cve.id).
95
- * When a finding exists in both, fad-check's row is kept but tagged source='both'.
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-check", "poms-cache");
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-check-transitive" });
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-check-transitive" } });
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.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.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-check.js",
15
- "fad": "fad-check.js"
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-check.js --compile --target=bun-linux-x64 --outfile=dist/fad-check-linux",
20
- "build:win": "bun build fad-check.js --compile --target=bun-windows-x64 --outfile=dist/fad-check.exe",
21
- "build:macos": "bun build fad-check.js --compile --target=bun-darwin-x64 --outfile=dist/fad-check-macos",
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-check-test-"));
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-check-target-"));
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-check-priv-"));
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-check-pv-"));
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);