fad-checker 1.0.6 → 2.0.1

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +77 -15
  3. package/completions/fad-checker.bash +4 -1
  4. package/completions/fad-checker.zsh +9 -0
  5. package/data/eol-mapping.json +17 -0
  6. package/fad-checker.js +353 -241
  7. package/lib/codecs/codec.interface.js +27 -0
  8. package/lib/codecs/composer/parse.js +59 -0
  9. package/lib/codecs/composer/registry.js +92 -0
  10. package/lib/codecs/composer.codec.js +93 -0
  11. package/lib/codecs/index.js +37 -0
  12. package/lib/codecs/maven.codec.js +71 -0
  13. package/lib/{npm → codecs/npm}/collect.js +58 -35
  14. package/lib/{npm → codecs/npm}/parse.js +114 -10
  15. package/lib/{npm → codecs/npm}/registry.js +4 -3
  16. package/lib/codecs/npm.codec.js +52 -0
  17. package/lib/codecs/nuget/parse.js +75 -0
  18. package/lib/codecs/nuget/registry.js +88 -0
  19. package/lib/codecs/nuget.codec.js +94 -0
  20. package/lib/codecs/pypi/parse.js +163 -0
  21. package/lib/codecs/pypi/registry.js +89 -0
  22. package/lib/codecs/pypi.codec.js +102 -0
  23. package/lib/codecs/recipes.js +108 -0
  24. package/lib/codecs/select.js +27 -0
  25. package/lib/codecs/yarn.codec.js +19 -0
  26. package/lib/core.js +35 -1
  27. package/lib/cve-match.js +18 -11
  28. package/lib/cve-report.js +34 -70
  29. package/lib/dep-record.js +60 -0
  30. package/lib/deps-descriptor.js +110 -0
  31. package/lib/nvd.js +4 -3
  32. package/lib/osv.js +29 -18
  33. package/lib/outdated.js +20 -4
  34. package/lib/retire.js +77 -13
  35. package/lib/transitive.js +3 -3
  36. package/lib/ui.js +87 -0
  37. package/package.json +4 -2
  38. package/CLAUDE.md +0 -129
  39. package/docs/ARCHITECTURE.md +0 -154
  40. package/docs/USAGE.md +0 -179
  41. package/test/core.test.js +0 -153
  42. package/test/cpe.test.js +0 -166
  43. package/test/cve-download.test.js +0 -39
  44. package/test/cve-match.test.js +0 -217
  45. package/test/cve-report.test.js +0 -171
  46. package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
  47. package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
  48. package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
  49. package/test/fixtures/complex-enterprise/pom.xml +0 -66
  50. package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
  51. package/test/fixtures/cve-samples/cve-non-java.json +0 -19
  52. package/test/fixtures/cve-samples/cve-product-only.json +0 -31
  53. package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
  54. package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
  55. package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
  56. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
  57. package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
  58. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
  59. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -9
  60. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
  61. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
  62. package/test/fixtures/monorepo-mixed/pom.xml +0 -29
  63. package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
  64. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
  65. package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
  66. package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
  67. package/test/fixtures/private-lib-detection/pom.xml +0 -35
  68. package/test/fixtures/simple/app/pom.xml +0 -28
  69. package/test/fixtures/simple/lib/pom.xml +0 -18
  70. package/test/fixtures/simple/pom.xml +0 -24
  71. package/test/maven-repo.test.js +0 -111
  72. package/test/maven-version.test.js +0 -57
  73. package/test/monorepo.test.js +0 -132
  74. package/test/npm-registry.test.js +0 -64
  75. package/test/npm.test.js +0 -146
  76. package/test/outdated.test.js +0 -101
  77. package/test/snyk.test.js +0 -64
  78. package/test/transitive.test.js +0 -305
  79. package/test/webjar.test.js +0 -33
@@ -0,0 +1,60 @@
1
+ /**
2
+ * lib/dep-record.js — contrat de données unifié partagé par tous les codecs.
3
+ *
4
+ * `coordKey` est la clé de la Map résolue ; elle ne collisionne jamais entre
5
+ * écosystèmes grâce au préfixe `ecosystem:`. `groupId`/`artifactId`/`pomPaths`
6
+ * sont conservés comme alias rétro-compat le temps de migrer tous les
7
+ * consommateurs vers `namespace`/`name`/`manifestPaths`.
8
+ */
9
+
10
+ // NuGet, Composer et PyPI sont case-insensitive : on normalise la clé en lower
11
+ // (l'affichage garde la casse d'origine via dep.name).
12
+ function normalizeForKey(ecosystem, s) {
13
+ if (ecosystem === "nuget" || ecosystem === "composer" || ecosystem === "pypi") return String(s).toLowerCase();
14
+ return s;
15
+ }
16
+
17
+ // La clé de la Map résolue.
18
+ // - Maven : clé BRUTE "g:a" (pas de préfixe "maven:"). Collision-free face aux
19
+ // préfixes des autres écosystèmes (un groupId reverse-DNS ne commence jamais
20
+ // par "npm:"/"nuget:"/…), et ça garde transitive.js + les tests inchangés.
21
+ // - npm : "npm:<name>" où name peut inclure le scope (@org/pkg) — namespace reste "".
22
+ // - composer : "composer:vendor/pkg". pypi/nuget : "<eco>:<name>".
23
+ function coordKeyFor(ecosystem, namespace, name) {
24
+ if (ecosystem === "maven") {
25
+ const g = namespace || "";
26
+ return g ? `${g}:${name}` : name;
27
+ }
28
+ const ns = normalizeForKey(ecosystem, namespace || "");
29
+ const nm = normalizeForKey(ecosystem, name || "");
30
+ const joined = ns ? `${ns}/${nm}` : nm;
31
+ return `${ecosystem}:${joined}`;
32
+ }
33
+
34
+ function makeDepRecord(input) {
35
+ const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType } = input;
36
+ const concrete = version && !/\$\{/.test(version) ? version : null;
37
+ const manifestPaths = manifestPath ? [manifestPath] : [];
38
+ return {
39
+ ecosystem,
40
+ ecosystemType: ecosystemType || ecosystem,
41
+ namespace: namespace || "",
42
+ name,
43
+ version: version || null,
44
+ versions: concrete ? [concrete] : [],
45
+ coordKey: coordKeyFor(ecosystem, namespace, name),
46
+ scope,
47
+ isDev: !!isDev,
48
+ manifestPaths,
49
+ // Alias rétro-compat : CHAMPS DUPLIQUÉS RÉELS (pas des getters — les depRecords
50
+ // sont spreadés dans des chemins chauds : cve-match.js:175, scan-completeness.js,
51
+ // cve-report.js:1036, snyk.js:105 — un getter serait perdu au spread).
52
+ // groupId/artifactId sont des strings jamais réassignées → pas de dérive.
53
+ // pomPaths PARTAGE la référence de manifestPaths → les push restent synchrones.
54
+ groupId: namespace || "",
55
+ artifactId: name,
56
+ pomPaths: manifestPaths,
57
+ };
58
+ }
59
+
60
+ module.exports = { makeDepRecord, coordKeyFor };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * lib/deps-descriptor.js — serialize / deserialize an *anonymized* descriptor of
3
+ * the resolved dependency set, for PASSI-style offline→online→offline audits.
4
+ *
5
+ * Phase 1 (offline): collect deps → serializeDeps → write JSON. No paths, URLs,
6
+ * hostnames or usernames leave the air-gapped machine — only public package
7
+ * coordinates (ecosystem / namespace / name / version) + scope.
8
+ * Phase 2 (online): read JSON → deserializeDeps → run the scan flow to warm the
9
+ * coordinate-keyed caches, then `--export-cache`.
10
+ * Phase 3 (offline): `--import-cache` + a normal `--offline` scan re-collects the
11
+ * source locally (real paths) and gets cache hits → full detailed report.
12
+ *
13
+ * See docs/superpowers/specs/2026-05-30-anonymized-deps-descriptor-passi-design.md
14
+ *
15
+ * Pure functions: no I/O, no console. The caller does file read/write.
16
+ */
17
+ const { makeDepRecord, coordKeyFor } = require("./dep-record");
18
+
19
+ const SCHEMA = "fad-deps/1";
20
+
21
+ // Fields kept in the descriptor — everything required to query the public vuln
22
+ // databases and to group the report. Anything path/URL/host-bearing is dropped:
23
+ // manifestPath(s) / pomPaths, resolved (registry URL), integrity, from
24
+ // (parent chain), depth, lockType.
25
+ const ANON_NOTE = "Anonymized: public coordinates only — no paths, URLs, or host info. Review before transfer.";
26
+
27
+ /**
28
+ * serializeDeps(resolvedMap, opts) -> descriptor object (ready for JSON.stringify).
29
+ *
30
+ * opts:
31
+ * generator — tool id/version string for traceability (no env info)
32
+ * generatedAt — ISO timestamp (override for deterministic tests)
33
+ * note — human-readable banner (defaults to the anonymization notice)
34
+ */
35
+ function serializeDeps(resolvedMap, opts = {}) {
36
+ const { generator = "fad-checker", generatedAt = new Date().toISOString(), note = ANON_NOTE } = opts;
37
+ const deps = [];
38
+ const byEcosystem = {};
39
+ for (const d of resolvedMap.values()) {
40
+ if (!d || !d.ecosystem || !d.name) continue;
41
+ const versions = Array.isArray(d.versions) && d.versions.length
42
+ ? [...new Set(d.versions)]
43
+ : (d.version ? [d.version] : []);
44
+ deps.push({
45
+ ecosystem: d.ecosystem,
46
+ ecosystemType: d.ecosystemType || d.ecosystem,
47
+ namespace: d.namespace || "",
48
+ name: d.name,
49
+ version: d.version || (versions[0] || null),
50
+ versions,
51
+ scope: d.scope || "prod",
52
+ isDev: !!d.isDev,
53
+ });
54
+ byEcosystem[d.ecosystem] = (byEcosystem[d.ecosystem] || 0) + 1;
55
+ }
56
+ // Stable order so two runs over the same tree produce identical files (easier
57
+ // to diff/review and to detect tampering during transfer).
58
+ deps.sort((a, b) => (a.ecosystem + "\0" + a.namespace + "\0" + a.name).localeCompare(b.ecosystem + "\0" + b.namespace + "\0" + b.name));
59
+ return {
60
+ schema: SCHEMA,
61
+ generator,
62
+ generatedAt,
63
+ note,
64
+ summary: { total: deps.length, byEcosystem },
65
+ deps,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * deserializeDeps(descriptor) -> { resolved, activeIds, runMaven, runNpm }.
71
+ *
72
+ * Rebuilds depRecords with EMPTY manifestPaths (the descriptor carries none) and
73
+ * a RECOMPUTED coordKey (never trusts the input). Throws on schema mismatch.
74
+ */
75
+ function deserializeDeps(descriptor) {
76
+ if (!descriptor || typeof descriptor !== "object") throw new Error("invalid descriptor: not an object");
77
+ if (descriptor.schema !== SCHEMA) {
78
+ throw new Error(`unsupported descriptor schema: got "${descriptor.schema}", expected "${SCHEMA}"`);
79
+ }
80
+ const list = Array.isArray(descriptor.deps) ? descriptor.deps : [];
81
+ const resolved = new Map();
82
+ const ecosystems = new Set();
83
+ for (const e of list) {
84
+ if (!e || !e.ecosystem || !e.name) continue;
85
+ const rec = makeDepRecord({
86
+ ecosystem: e.ecosystem,
87
+ ecosystemType: e.ecosystemType || e.ecosystem,
88
+ namespace: e.namespace || "",
89
+ name: e.name,
90
+ version: e.version || null,
91
+ scope: e.scope || "prod",
92
+ isDev: !!e.isDev,
93
+ // manifestPath intentionally omitted → manifestPaths/pomPaths = []
94
+ });
95
+ // Restore the full multi-version set (makeDepRecord only derives it from the
96
+ // single `version`); CVE/OSV matching iterates `versions`.
97
+ if (Array.isArray(e.versions) && e.versions.length) rec.versions = [...new Set(e.versions)];
98
+ resolved.set(coordKeyFor(e.ecosystem, e.namespace || "", e.name), rec);
99
+ ecosystems.add(e.ecosystem);
100
+ }
101
+ const activeIds = [...ecosystems];
102
+ return {
103
+ resolved,
104
+ activeIds,
105
+ runMaven: ecosystems.has("maven"),
106
+ runNpm: ecosystems.has("npm") || ecosystems.has("yarn"),
107
+ };
108
+ }
109
+
110
+ module.exports = { serializeDeps, deserializeDeps, SCHEMA, ANON_NOTE };
package/lib/nvd.js CHANGED
@@ -191,7 +191,7 @@ async function fetchOne(cveId, opts = {}) {
191
191
  * Rate limited per the NIST policy (use NVD_API_KEY for faster access).
192
192
  */
193
193
  async function enrichMatches(matches, opts = {}) {
194
- const { offline } = opts;
194
+ const { offline, onProgress } = opts;
195
195
  const uniqueCves = new Set();
196
196
  for (const m of matches) if (m.cve?.id?.startsWith("CVE-")) uniqueCves.add(m.cve.id);
197
197
  const hasKey = !!getNvdApiKey();
@@ -212,13 +212,13 @@ async function enrichMatches(matches, opts = {}) {
212
212
  liveIds.push(cveId);
213
213
  }
214
214
 
215
- if (liveIds.length) {
215
+ if (liveIds.length && !onProgress) {
216
216
  const etaSec = Math.ceil((liveIds.length / burst) * (NVD_WINDOW_MS / 1000));
217
217
  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";
218
218
  const cachedCount = byId.size;
219
219
  const cachedHint = cachedCount ? `, ${cachedCount} cached` : "";
220
220
  console.log(`🔍 NVD: enriching ${liveIds.length} CVEs${cachedHint} — ${keyHint}. ETA ~${etaSec}s.`);
221
- } else if (uniqueCves.size) {
221
+ } else if (uniqueCves.size && !onProgress) {
222
222
  console.log(`🔍 NVD: ${uniqueCves.size} CVEs (all cached).`);
223
223
  }
224
224
 
@@ -229,6 +229,7 @@ async function enrichMatches(matches, opts = {}) {
229
229
  const startedAt = [];
230
230
  const startProgress = Date.now();
231
231
  const printProgress = (final = false) => {
232
+ if (onProgress) { onProgress(done, liveIds.length); return; }
232
233
  const elapsed = Math.round((Date.now() - startProgress) / 1000);
233
234
  const pct = liveIds.length ? Math.round((done / liveIds.length) * 100) : 100;
234
235
  const line = ` NVD: ${done}/${liveIds.length} (${pct}%) — ${elapsed}s elapsed`;
package/lib/osv.js CHANGED
@@ -96,8 +96,18 @@ function pickPrimaryId(vuln) {
96
96
  return cveAlias || vuln.id;
97
97
  }
98
98
 
99
- /** Build the OSV package-name key for a dep (Maven uses g:a, npm uses bare name). */
99
+ // OSV ecosystem name per codec id. OSV.dev natively supports all of these.
100
+ const OSV_ECO = { maven: "Maven", npm: "npm", yarn: "npm", nuget: "NuGet", composer: "Packagist", pypi: "PyPI" };
101
+
102
+ /** OSV ecosystem string for a dep, derived from its codec id. */
103
+ function osvEcosystemFor(dep) { return OSV_ECO[dep.ecosystem] || "Maven"; }
104
+
105
+ /** Build the OSV package-name key for a dep — delegated to the dep's codec. */
100
106
  function osvPkgName(dep) {
107
+ const { getCodec } = require("./codecs");
108
+ const c = getCodec(dep.ecosystem);
109
+ if (c) return c.osvPackageName(dep);
110
+ // Fallback historique si le codec est introuvable.
101
111
  return dep.ecosystem === "npm" ? dep.artifactId : `${dep.groupId}:${dep.artifactId}`;
102
112
  }
103
113
 
@@ -142,7 +152,7 @@ function vulnToMatch(dep, vuln) {
142
152
  }
143
153
 
144
154
  async function queryBatch(deps, opts = {}) {
145
- const { verbose, offline, fetcher = globalThis.fetch } = opts;
155
+ const { verbose, offline, fetcher = globalThis.fetch, onProgress } = opts;
146
156
  // Build query list + parallel cached/uncached split
147
157
  const queries = [];
148
158
  const indexMap = []; // index in `deps` → either { cached: vulns } or { queryIdx: N }
@@ -155,11 +165,10 @@ async function queryBatch(deps, opts = {}) {
155
165
  indexMap[i] = { cached: hit };
156
166
  } else {
157
167
  indexMap[i] = { queryIdx: queries.length, cacheKey: ck };
158
- // Ecosystem-aware query: OSV uses "npm" for npm packages (name only,
159
- // no groupId), "Maven" for Maven (name = "g:a"). The dep record's
160
- // `ecosystem` field defaults to "maven" — npm collector sets "npm".
161
- const ecosystem = d.ecosystem === "npm" ? "npm" : "Maven";
162
- const pkgName = ecosystem === "npm" ? d.artifactId : `${d.groupId}:${d.artifactId}`;
168
+ // Ecosystem + package name are both derived from the dep's codec, so
169
+ // adding a new ecosystem (NuGet/Packagist/PyPI) needs no change here.
170
+ const ecosystem = osvEcosystemFor(d);
171
+ const pkgName = osvPkgName(d);
163
172
  queries.push({
164
173
  package: { name: pkgName, ecosystem },
165
174
  version: d.version,
@@ -178,7 +187,8 @@ async function queryBatch(deps, opts = {}) {
178
187
  let batchIdx = 0;
179
188
  for (const batch of batches) {
180
189
  batchIdx++;
181
- if (process.stdout.isTTY) process.stdout.write(`\r OSV batch ${batchIdx}/${batches.length} (${batch.length} deps)… `);
190
+ if (onProgress) onProgress(batchIdx, batches.length);
191
+ else if (process.stdout.isTTY) process.stdout.write(`\r OSV batch ${batchIdx}/${batches.length} (${batch.length} deps)… `);
182
192
  else if (batches.length > 1) console.log(` OSV batch ${batchIdx}/${batches.length} (${batch.length} deps)…`);
183
193
  const res = await fetcher(`${OSV_BASE}/v1/querybatch`, {
184
194
  method: "POST",
@@ -196,7 +206,8 @@ async function queryBatch(deps, opts = {}) {
196
206
  allResults[(batchIdx - 1) * BATCH_SIZE + j] = results[j] || { vulns: [] };
197
207
  }
198
208
  }
199
- if (process.stdout.isTTY) process.stdout.write(`\r OSV batches complete (${batches.length}) \n`);
209
+ if (onProgress) { /* finalized by caller */ }
210
+ else if (process.stdout.isTTY) process.stdout.write(`\r OSV batches complete (${batches.length}) \n`);
200
211
 
201
212
  // Persist per-dep cache (stub list — details cached separately)
202
213
  for (let i = 0; i < deps.length; i++) {
@@ -230,12 +241,13 @@ async function queryBatch(deps, opts = {}) {
230
241
  }
231
242
 
232
243
  if (liveIds.length) {
233
- console.log(` OSV details: ${liveIds.length} to fetch${allIds.size - liveIds.length ? `, ${allIds.size - liveIds.length} cached` : ""}…`);
244
+ if (!onProgress) console.log(` OSV details: ${liveIds.length} to fetch${allIds.size - liveIds.length ? `, ${allIds.size - liveIds.length} cached` : ""}…`);
234
245
  const concurrency = 10;
235
246
  let cursor = 0;
236
247
  let fetched = 0;
237
248
  const startedAt = Date.now();
238
249
  const printOsvProgress = (final = false) => {
250
+ if (onProgress) { onProgress(fetched, liveIds.length); return; }
239
251
  const elapsed = Math.round((Date.now() - startedAt) / 1000);
240
252
  const pct = Math.round((fetched / liveIds.length) * 100);
241
253
  const line = ` OSV details: ${fetched}/${liveIds.length} (${pct}%) — ${elapsed}s`;
@@ -306,14 +318,11 @@ async function queryOsvForDeps(resolvedDeps, opts = {}) {
306
318
  // need a concrete version. Lockfiles always provide one; package.json-
307
319
  // only deps are skipped here.
308
320
  if (d.ecosystem === "npm" && /^[\^~>=<*]|^(latest|next|workspace:|git\+|file:|link:)/.test(ver)) continue;
309
- deps.push({
310
- groupId: d.groupId, artifactId: d.artifactId, version: ver,
311
- scope: d.scope, via: d.via, depth: d.depth, pomPaths: d.pomPaths,
312
- manifestPaths: d.manifestPaths,
313
- ecosystem: d.ecosystem || "maven",
314
- ecosystemType: d.ecosystemType,
315
- isDev: !!d.isDev,
316
- });
321
+ // Spread the whole depRecord (carries namespace/name/coordKey, which the
322
+ // codec's osvPackageName + the report's dedup rely on) and override only
323
+ // the version for this per-version query. Cherry-picking fields here used
324
+ // to drop name/namespace → OSV queried `undefined` for composer/pypi/nuget.
325
+ deps.push({ ...d, version: ver });
317
326
  }
318
327
  }
319
328
  return queryBatch(deps, opts);
@@ -325,5 +334,7 @@ module.exports = {
325
334
  vulnToMatch,
326
335
  severityFromOsv,
327
336
  fixVersionFromOsv,
337
+ osvEcosystemFor,
338
+ osvPkgName,
328
339
  OSV_CACHE_DIR,
329
340
  };
package/lib/outdated.js CHANGED
@@ -9,7 +9,7 @@ const fs = require("fs");
9
9
  const path = require("path");
10
10
  const os = require("os");
11
11
  const { compareMavenVersions } = require("./maven-version");
12
- const { webjarToNpm } = require("./npm/collect");
12
+ const { webjarToNpm } = require("./codecs/npm/collect");
13
13
 
14
14
  const KNOWN_OBSOLETE = require("../data/known-obsolete.json");
15
15
  const EOL_MAPPING = require("../data/eol-mapping.json");
@@ -57,6 +57,16 @@ function findEolProduct(dep) {
57
57
  }
58
58
  return null;
59
59
  }
60
+ if (dep.ecosystem === "composer") {
61
+ const full = `${dep.namespace || dep.groupId || ""}/${dep.name || dep.artifactId}`.toLowerCase();
62
+ return EOL_MAPPING.by_composer_name?.[full] || null;
63
+ }
64
+ if (dep.ecosystem === "pypi") {
65
+ return EOL_MAPPING.by_pypi_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
66
+ }
67
+ if (dep.ecosystem === "nuget") {
68
+ return EOL_MAPPING.by_nuget_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
69
+ }
60
70
  const key = `${dep.groupId}:${dep.artifactId}`;
61
71
  const direct = EOL_MAPPING.by_group_artifact?.[key];
62
72
  if (direct) return direct;
@@ -241,18 +251,19 @@ function parseMavenMetadataLatest(xml) {
241
251
  }
242
252
 
243
253
  async function checkOutdatedDeps(resolvedDeps, opts = {}) {
244
- const { verbose, offline, concurrency = 8, repos } = opts;
254
+ const { verbose, offline, concurrency = 8, repos, onProgress } = opts;
245
255
  const cache = loadJsonCache(VERSION_CACHE_PATH);
246
256
  const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < VERSION_CACHE_MAX_AGE_MS;
247
257
  if (!fresh && !offline) cache.entries = {};
248
- const list = [...resolvedDeps.values()].filter(d => d.version && !/\$\{|SNAPSHOT/i.test(d.version) && d.ecosystem !== "npm");
258
+ // Maven Central only npm/composer/pypi/nuget have their own registries (codec.checkRegistry).
259
+ const list = [...resolvedDeps.values()].filter(d => d.version && !/\$\{|SNAPSHOT/i.test(d.version) && d.ecosystem === "maven");
249
260
  const results = [];
250
261
 
251
262
  // Progress indicator — Maven Central can serve hundreds of deps in a few
252
263
  // seconds with 8-way concurrency, but on first run (cold cache) the user
253
264
  // would see total silence for 20-60s.
254
265
  const liveCount = offline ? 0 : list.filter(d => !cache.entries[`${d.groupId}:${d.artifactId}`]).length;
255
- if (liveCount && !offline) {
266
+ if (liveCount && !offline && !onProgress) {
256
267
  console.log(`📅 Outdated: checking ${list.length} deps against Maven Central (${liveCount} live, ${list.length - liveCount} cached)…`);
257
268
  }
258
269
 
@@ -260,6 +271,7 @@ async function checkOutdatedDeps(resolvedDeps, opts = {}) {
260
271
  let processed = 0;
261
272
  const startedAt = Date.now();
262
273
  const printOutdatedProgress = (final = false) => {
274
+ if (onProgress) { onProgress(processed, list.length); return; }
263
275
  if (!liveCount) return;
264
276
  const elapsed = Math.round((Date.now() - startedAt) / 1000);
265
277
  const pct = Math.round((processed / list.length) * 100);
@@ -297,6 +309,10 @@ module.exports = {
297
309
  checkObsolete,
298
310
  checkOutdatedDeps,
299
311
  findEolProduct,
312
+ findCycleForVersion,
313
+ isEol,
300
314
  isEolCacheFresh,
315
+ EOL_MAPPING,
316
+ EOL_CACHE_PATH,
301
317
  KNOWN_OBSOLETE,
302
318
  };
package/lib/retire.js CHANGED
@@ -24,6 +24,15 @@ const execFileP = promisify(execFile);
24
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
+ // retire's own signature DB. By default retire caches it in /tmp/.retire-cache
28
+ // (outside ~/.fad-checker/, with a 1h TTL → a network refetch on expiry). For the
29
+ // PASSI offline workflow we instead keep a stable local copy INSIDE ~/.fad-checker/
30
+ // so `--export-cache` carries it, and feed it to retire via `--jsrepo <file>`
31
+ // (loaded from file, never the network — no TTL).
32
+ const RETIRE_SIG_DIR = path.join(os.homedir(), ".fad-checker", "retire-signatures");
33
+ const RETIRE_SIG_FILE = path.join(RETIRE_SIG_DIR, "jsrepository-v5.json");
34
+ const RETIRE_REPO_URL = "https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository-v5.json";
35
+
27
36
  function cacheKey(srcDir) {
28
37
  return crypto.createHash("md5").update(path.resolve(srcDir)).digest("hex") + ".json";
29
38
  }
@@ -50,6 +59,54 @@ function findRetireBin() {
50
59
  return "retire"; // fall back to PATH
51
60
  }
52
61
 
62
+ /**
63
+ * Fetch retire's signature DB to a stable file inside ~/.fad-checker/ so it can
64
+ * be bundled by --export-cache and reused offline via --jsrepo. Network call —
65
+ * online only. Returns { ok, path } | { ok:false, error }. Never throws.
66
+ */
67
+ async function warmRetireSignatures(opts = {}) {
68
+ const { verbose, force } = opts;
69
+ if (!force && fs.existsSync(RETIRE_SIG_FILE)) {
70
+ if (verbose) console.log(`retire: signatures already present (${RETIRE_SIG_FILE})`);
71
+ return { ok: true, path: RETIRE_SIG_FILE, cached: true };
72
+ }
73
+ try {
74
+ const res = await fetch(RETIRE_REPO_URL);
75
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
76
+ const text = await res.text();
77
+ JSON.parse(text); // validate it's the repo JSON before persisting
78
+ fs.mkdirSync(RETIRE_SIG_DIR, { recursive: true });
79
+ fs.writeFileSync(RETIRE_SIG_FILE, text);
80
+ if (verbose) console.log(`retire: signatures warmed → ${RETIRE_SIG_FILE}`);
81
+ return { ok: true, path: RETIRE_SIG_FILE };
82
+ } catch (e) {
83
+ if (verbose) console.warn(`retire: signature warm failed — ${e.message}`);
84
+ return { ok: false, error: e.message };
85
+ }
86
+ }
87
+
88
+ // Ensure a local signature file exists. Online: fetch it if missing. Offline:
89
+ // only report whether it's already there (no network).
90
+ async function ensureSignatures({ verbose, offline } = {}) {
91
+ if (fs.existsSync(RETIRE_SIG_FILE)) return true;
92
+ if (offline) return false;
93
+ const r = await warmRetireSignatures({ verbose });
94
+ return !!r.ok;
95
+ }
96
+
97
+ // Pure: build the retire CLI argv. `jsRepo` (a local signature file) is added
98
+ // when available so retire loads signatures from disk instead of the network.
99
+ function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
100
+ const args = [
101
+ "--outputformat", "json",
102
+ "--outputpath", outPath,
103
+ "--jspath", srcDir,
104
+ "--ignore", ignoredDirs,
105
+ ];
106
+ if (jsRepo) { args.push("--jsrepo", jsRepo); }
107
+ return args;
108
+ }
109
+
53
110
  /**
54
111
  * Run retire.js against `srcDir`. Returns the parsed JSON output (an array
55
112
  * of file-level findings) or null if retire is unavailable.
@@ -59,12 +116,9 @@ function findRetireBin() {
59
116
  */
60
117
  async function runRetire(srcDir, opts = {}) {
61
118
  const { verbose, force, offline } = opts;
62
- if (offline) {
63
- const cached = readCache(srcDir);
64
- if (cached) return cached;
65
- if (verbose) console.warn("retire: --offline and no cache — skipped");
66
- return null;
67
- }
119
+ // No source tree (e.g. --import-anonymized) → nothing to scan for vendored JS.
120
+ if (!srcDir) { if (verbose) console.warn("retire: no source dir — skipped"); return null; }
121
+ // Findings-cache fast path (path-keyed). Works online and offline.
68
122
  if (!force) {
69
123
  const cached = readCache(srcDir);
70
124
  if (cached) {
@@ -73,6 +127,15 @@ async function runRetire(srcDir, opts = {}) {
73
127
  }
74
128
  }
75
129
 
130
+ // No findings cache → we must scan. We need a signature DB. Offline, we can
131
+ // only proceed if a local signature file was previously warmed (and bundled
132
+ // via --export-cache / --import-cache); otherwise honor --offline and skip.
133
+ const haveSig = await ensureSignatures({ verbose, offline });
134
+ if (offline && !haveSig) {
135
+ if (verbose) console.warn("retire: --offline, no findings cache and no local signatures — skipped");
136
+ return null;
137
+ }
138
+
76
139
  const bin = findRetireBin();
77
140
  const ignoredDirs = [
78
141
  "node_modules", "bower_components", "jspm_packages",
@@ -83,13 +146,8 @@ async function runRetire(srcDir, opts = {}) {
83
146
  // retire.js refuses to write to /dev/stdout, so we use a real temp file
84
147
  // and read it back. Falls back to stdout if --outputpath is rejected.
85
148
  const tmpOut = path.join(os.tmpdir(), `fad-checker-retire-${process.pid}-${Date.now()}.json`);
86
- const args = [
87
- "--outputformat", "json",
88
- "--outputpath", tmpOut,
89
- "--jspath", srcDir,
90
- "--ignore", ignoredDirs,
91
- ];
92
- if (verbose) console.log(`retire: scanning ${srcDir}…`);
149
+ const args = buildRetireArgs({ srcDir, outPath: tmpOut, ignoredDirs, jsRepo: haveSig ? RETIRE_SIG_FILE : null });
150
+ if (verbose) console.log(`retire: scanning ${srcDir}…${haveSig ? " (local signatures)" : ""}`);
93
151
 
94
152
  try {
95
153
  // retire.js exits with code 13 when it finds vulnerabilities — that's
@@ -207,5 +265,11 @@ module.exports = {
207
265
  runRetire,
208
266
  normaliseRetireResults,
209
267
  findRetireBin,
268
+ warmRetireSignatures,
269
+ ensureSignatures,
270
+ buildRetireArgs,
210
271
  RETIRE_CACHE_DIR,
272
+ RETIRE_SIG_DIR,
273
+ RETIRE_SIG_FILE,
274
+ RETIRE_REPO_URL,
211
275
  };
package/lib/transitive.js CHANGED
@@ -396,12 +396,12 @@ async function resolveTransitiveDeps(directDeps, opts = {}) {
396
396
  // Progress visible whenever stdout is a TTY — transitive resolution
397
397
  // dominates wall-clock time on first run and used to look like a hang.
398
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} `);
399
+ if (verbose && process.stdout.isTTY) process.stdout.write(`\r resolved ${out.size} transitives, queue=${queue.length - head} `);
400
400
  }
401
401
  });
402
402
  await Promise.all(workers);
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`);
403
+ if (verbose && process.stdout.isTTY) process.stdout.write(`\r resolved ${out.size} transitives \n`);
404
+ else if (verbose && out.size) console.log(` resolved ${out.size} transitives`);
405
405
 
406
406
  return out;
407
407
  }
package/lib/ui.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * lib/ui.js — shared CLI presentation: banner, section headers, severity colors,
3
+ * and a global step progress indicator for the cache/database update phase.
4
+ *
5
+ * The Progress indicator renders a "[n/N] <spinner> label — summary" checklist.
6
+ * TTY: one animated line per step, rewritten in place, finalized with ✓/⊘/✗.
7
+ * Non-TTY (pipes, CI, files): a single plain line per finished step, no escapes.
8
+ */
9
+ const chalk = require("chalk");
10
+
11
+ const isTTY = !!(process.stdout && process.stdout.isTTY) && process.env.TERM !== "dumb";
12
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
13
+
14
+ const TITLE_A = "fad-checker";
15
+ const TITLE_B = "Autonomous Dependency Checker";
16
+
17
+ function banner() {
18
+ const raw = `${TITLE_A} · ${TITLE_B}`;
19
+ const bar = "─".repeat(raw.length + 2);
20
+ console.log(chalk.cyan(`\n╭${bar}╮`));
21
+ console.log(chalk.cyan("│ ") + chalk.bold.white(TITLE_A) + chalk.cyan(" · ") + chalk.whiteBright(TITLE_B) + chalk.cyan(" │"));
22
+ console.log(chalk.cyan(`╰${bar}╯`));
23
+ }
24
+
25
+ function section(title) {
26
+ console.log(chalk.bold.cyan("\n▸ ") + chalk.bold(title));
27
+ }
28
+
29
+ // " label value" aligned key/value line under a section.
30
+ function kv(label, value, { pad = 10 } = {}) {
31
+ console.log(" " + chalk.dim(String(label).padEnd(pad)) + " " + value);
32
+ }
33
+
34
+ function ok(msg) { console.log(" " + chalk.green("✓") + " " + msg); }
35
+ function warn(msg) { console.log(" " + chalk.yellow("⚠") + " " + msg); }
36
+ function info(msg) { console.log(" " + chalk.dim("·") + " " + msg); }
37
+
38
+ function sevColor(sev) {
39
+ switch (String(sev || "").toUpperCase()) {
40
+ case "CRITICAL": return chalk.bold.red;
41
+ case "HIGH": return chalk.red;
42
+ case "MEDIUM": return chalk.yellow;
43
+ case "LOW": return chalk.blue;
44
+ default: return chalk.gray;
45
+ }
46
+ }
47
+
48
+ class Step {
49
+ constructor(n, total, label) {
50
+ this.n = n; this.total = total; this.label = label;
51
+ this.live = ""; this.frame = 0; this.timer = null; this.ended = false;
52
+ this.prefix = chalk.dim(`[${n}/${total}]`);
53
+ if (isTTY) {
54
+ this._render();
55
+ this.timer = setInterval(() => { this.frame++; this._render(); }, 80);
56
+ if (this.timer.unref) this.timer.unref();
57
+ }
58
+ }
59
+ _render() {
60
+ if (!isTTY || this.ended) return;
61
+ const sp = chalk.cyan(SPINNER[this.frame % SPINNER.length]);
62
+ const live = this.live ? chalk.dim(" " + this.live) : chalk.dim(" …");
63
+ process.stdout.write(`\r ${this.prefix} ${sp} ${this.label}${live}\x1b[K`);
64
+ }
65
+ tick(processed, total) {
66
+ this.live = total ? `${processed}/${total}` : String(processed || "");
67
+ // rendering is driven by the timer; nothing to print on non-TTY
68
+ }
69
+ _finalize(symbol, color, summary) {
70
+ if (this.ended) return;
71
+ this.ended = true;
72
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
73
+ const line = ` ${this.prefix} ${color(symbol)} ${this.label}` + (summary ? chalk.dim(" — " + summary) : "");
74
+ if (isTTY) process.stdout.write(`\r${line}\x1b[K\n`);
75
+ else console.log(line);
76
+ }
77
+ done(summary) { this._finalize("✓", chalk.green, summary); }
78
+ skip(reason) { this._finalize("⊘", chalk.gray, reason); }
79
+ fail(msg) { this._finalize("✗", chalk.red, msg); }
80
+ }
81
+
82
+ class Progress {
83
+ constructor(total) { this.total = total || 0; this.n = 0; }
84
+ start(label) { this.n += 1; return new Step(this.n, this.total, label); }
85
+ }
86
+
87
+ module.exports = { banner, section, kv, ok, warn, info, sevColor, Progress, isTTY };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "1.0.6",
4
- "description": "Fucking Autonomous Dependency Checker — Scans ALL pom/bom, npm/Yarn stuff and vendored JavaScript in a source tree, deal with private deps, multi modules & produces 1 HTML/DOC report with CVE, EOL, obsolete, outdated deps & fix recos",
3
+ "version": "2.0.1",
4
+ "description": "Scan ALL Maven, npm, Yarn, Composer, Python & C#/.NET dependencies in a source tree ONE SHOT without mvn/python/etc, deal with private deps, multi mvn modules, EOL, obsolete, outdated deps & give fix recos",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/n8tz/fad-checker",
7
7
  "author": "pp9Ping <pp9Ping@gmail.com>",
@@ -25,9 +25,11 @@
25
25
  "dependencies": {
26
26
  "chalk": "^4.1.2",
27
27
  "commander": "^14.0.1",
28
+ "js-yaml": "^4.1.1",
28
29
  "p-limit": "^3.1.0",
29
30
  "retire": "^5.4.2",
30
31
  "rimraf": "^6.0.1",
32
+ "smol-toml": "^1.6.1",
31
33
  "xml2js": "^0.6.2"
32
34
  },
33
35
  "engines": {