fad-checker 2.0.0 → 2.1.0

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 (139) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +147 -25
  3. package/completions/fad-checker.bash +1 -1
  4. package/completions/fad-checker.zsh +14 -1
  5. package/data/eol-mapping.json +5 -5
  6. package/data/license-policy.json +97 -0
  7. package/fad-checker-report/cve-report.doc +630 -0
  8. package/fad-checker-report/cve-report.html +748 -0
  9. package/fad-checker.js +556 -245
  10. package/lib/cache-archive.js +3 -0
  11. package/lib/codecs/codec.interface.js +3 -0
  12. package/lib/codecs/composer/parse.js +3 -0
  13. package/lib/codecs/composer/registry.js +13 -5
  14. package/lib/codecs/composer.codec.js +3 -0
  15. package/lib/codecs/go/parse.js +65 -0
  16. package/lib/codecs/go/registry.js +74 -0
  17. package/lib/codecs/go.codec.js +76 -0
  18. package/lib/codecs/index.js +7 -2
  19. package/lib/codecs/maven/jar-scan.js +199 -0
  20. package/lib/codecs/maven.codec.js +17 -1
  21. package/lib/codecs/npm/collect.js +37 -17
  22. package/lib/codecs/npm/parse.js +117 -10
  23. package/lib/codecs/npm/registry.js +14 -5
  24. package/lib/codecs/npm.codec.js +4 -1
  25. package/lib/codecs/nuget/parse.js +3 -0
  26. package/lib/codecs/nuget/registry.js +11 -4
  27. package/lib/codecs/nuget.codec.js +7 -2
  28. package/lib/codecs/pypi/parse.js +111 -14
  29. package/lib/codecs/pypi/registry.js +24 -5
  30. package/lib/codecs/pypi.codec.js +30 -6
  31. package/lib/codecs/recipes.js +28 -1
  32. package/lib/codecs/ruby/parse.js +42 -0
  33. package/lib/codecs/ruby/registry.js +76 -0
  34. package/lib/codecs/ruby.codec.js +66 -0
  35. package/lib/codecs/select.js +3 -0
  36. package/lib/codecs/yarn.codec.js +3 -0
  37. package/lib/config.js +3 -0
  38. package/lib/core.js +38 -1
  39. package/lib/cpe.js +30 -5
  40. package/lib/csaf-export.js +159 -0
  41. package/lib/cve-download.js +3 -0
  42. package/lib/cve-match.js +27 -6
  43. package/lib/cve-report.js +157 -28
  44. package/lib/dep-record.js +15 -2
  45. package/lib/deps-descriptor.js +113 -0
  46. package/lib/epss.js +115 -0
  47. package/lib/gate.js +45 -0
  48. package/lib/json-export.js +110 -0
  49. package/lib/kev.js +88 -0
  50. package/lib/license-policy.js +110 -0
  51. package/lib/maven-license.js +52 -0
  52. package/lib/maven-repo.js +3 -0
  53. package/lib/maven-version.js +8 -3
  54. package/lib/nvd.js +17 -6
  55. package/lib/osv.js +75 -23
  56. package/lib/outdated.js +10 -2
  57. package/lib/priority.js +90 -0
  58. package/lib/purl.js +77 -0
  59. package/lib/retire.js +80 -13
  60. package/lib/sarif-export.js +134 -0
  61. package/lib/sbom-export.js +153 -0
  62. package/lib/scan-completeness.js +3 -0
  63. package/lib/snyk.js +3 -0
  64. package/lib/suppress.js +113 -0
  65. package/lib/transitive.js +6 -3
  66. package/lib/ui.js +90 -0
  67. package/package.json +49 -2
  68. package/CLAUDE.md +0 -147
  69. package/docs/ARCHITECTURE.md +0 -182
  70. package/docs/USAGE.md +0 -187
  71. package/docs/superpowers/plans/2026-05-29-codec-composer.md +0 -556
  72. package/docs/superpowers/plans/2026-05-29-codec-foundation.md +0 -851
  73. package/docs/superpowers/plans/2026-05-29-codec-nuget.md +0 -432
  74. package/docs/superpowers/plans/2026-05-29-codec-pypi.md +0 -450
  75. package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +0 -251
  76. package/test/cli-ecosystem.test.js +0 -30
  77. package/test/codec-edge-cases.test.js +0 -131
  78. package/test/codec-integration.test.js +0 -119
  79. package/test/codecs.test.js +0 -92
  80. package/test/composer.test.js +0 -71
  81. package/test/core.test.js +0 -153
  82. package/test/cpe.test.js +0 -166
  83. package/test/cve-download.test.js +0 -39
  84. package/test/cve-match.test.js +0 -217
  85. package/test/cve-report.test.js +0 -180
  86. package/test/dep-record.test.js +0 -31
  87. package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
  88. package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
  89. package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
  90. package/test/fixtures/complex-enterprise/pom.xml +0 -66
  91. package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
  92. package/test/fixtures/csharp-config/packages.config +0 -4
  93. package/test/fixtures/csharp-csproj/Directory.Packages.props +0 -5
  94. package/test/fixtures/csharp-csproj/app.csproj +0 -7
  95. package/test/fixtures/csharp-lock/packages.lock.json +0 -6
  96. package/test/fixtures/cve-samples/cve-non-java.json +0 -19
  97. package/test/fixtures/cve-samples/cve-product-only.json +0 -31
  98. package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
  99. package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
  100. package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
  101. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
  102. package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
  103. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
  104. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -10
  105. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
  106. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
  107. package/test/fixtures/monorepo-mixed/pom.xml +0 -29
  108. package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
  109. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
  110. package/test/fixtures/php-app/composer.json +0 -5
  111. package/test/fixtures/php-app/composer.lock +0 -10
  112. package/test/fixtures/polyglot/cs/packages.lock.json +0 -1
  113. package/test/fixtures/polyglot/js/package-lock.json +0 -1
  114. package/test/fixtures/polyglot/js/package.json +0 -1
  115. package/test/fixtures/polyglot/mvn/pom.xml +0 -7
  116. package/test/fixtures/polyglot/php/composer.lock +0 -1
  117. package/test/fixtures/polyglot/py/poetry.lock +0 -3
  118. package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
  119. package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
  120. package/test/fixtures/private-lib-detection/pom.xml +0 -35
  121. package/test/fixtures/python-pipenv/Pipfile.lock +0 -1
  122. package/test/fixtures/python-poetry/poetry.lock +0 -7
  123. package/test/fixtures/python-reqs/requirements.txt +0 -5
  124. package/test/fixtures/python-uv/uv.lock +0 -3
  125. package/test/fixtures/simple/app/pom.xml +0 -28
  126. package/test/fixtures/simple/lib/pom.xml +0 -18
  127. package/test/fixtures/simple/pom.xml +0 -24
  128. package/test/maven-repo.test.js +0 -111
  129. package/test/maven-version.test.js +0 -57
  130. package/test/monorepo.test.js +0 -132
  131. package/test/npm-registry.test.js +0 -64
  132. package/test/npm.test.js +0 -150
  133. package/test/nuget.test.js +0 -66
  134. package/test/osv.test.js +0 -62
  135. package/test/outdated.test.js +0 -101
  136. package/test/pypi.test.js +0 -72
  137. package/test/snyk.test.js +0 -64
  138. package/test/transitive.test.js +0 -305
  139. package/test/webjar.test.js +0 -33
package/lib/nvd.js CHANGED
@@ -10,6 +10,9 @@
10
10
  * Rate limit: 5 req / 30s unauthenticated, 50 req / 30s with NVD_API_KEY env var.
11
11
  *
12
12
  * Cache: ~/.fad-checker/nvd-cache/<cve-id>.json, 7-day TTL.
13
+ *
14
+ * @author: N.BRAUN
15
+ * @email: pp9ping@gmail.com
13
16
  */
14
17
  const fs = require("fs");
15
18
  const path = require("path");
@@ -62,11 +65,15 @@ function writeCache(cveId, body) {
62
65
  function bestMetric(metrics) {
63
66
  // NVD 2.0 returns metrics organised by version: cvssMetricV40, V31, V30, V2
64
67
  const order = ["cvssMetricV40", "cvssMetricV31", "cvssMetricV30", "cvssMetricV2"];
68
+ // Proper CVSS version label — NOT "cvssMetricV31".replace(...) which yielded the
69
+ // malformed "CVSS:V31" that downstream consumers (csaf cvssV3Version, sbom
70
+ // cvssMethod, the report) test with .includes("3.1") and so always missed.
71
+ const VER = { cvssMetricV40: "CVSS:4.0", cvssMetricV31: "CVSS:3.1", cvssMetricV30: "CVSS:3.0", cvssMetricV2: "CVSS:2.0" };
65
72
  for (const k of order) {
66
73
  if (Array.isArray(metrics?.[k]) && metrics[k].length) {
67
74
  const cv = metrics[k][0].cvssData;
68
75
  return {
69
- version: k.replace("cvssMetric", "CVSS:"),
76
+ version: VER[k],
70
77
  score: cv.baseScore,
71
78
  severity: (cv.baseSeverity || severityFromScore(cv.baseScore) || "UNKNOWN").toUpperCase(),
72
79
  vector: cv.vectorString,
@@ -191,7 +198,7 @@ async function fetchOne(cveId, opts = {}) {
191
198
  * Rate limited per the NIST policy (use NVD_API_KEY for faster access).
192
199
  */
193
200
  async function enrichMatches(matches, opts = {}) {
194
- const { offline } = opts;
201
+ const { offline, onProgress } = opts;
195
202
  const uniqueCves = new Set();
196
203
  for (const m of matches) if (m.cve?.id?.startsWith("CVE-")) uniqueCves.add(m.cve.id);
197
204
  const hasKey = !!getNvdApiKey();
@@ -212,13 +219,13 @@ async function enrichMatches(matches, opts = {}) {
212
219
  liveIds.push(cveId);
213
220
  }
214
221
 
215
- if (liveIds.length) {
222
+ if (liveIds.length && !onProgress) {
216
223
  const etaSec = Math.ceil((liveIds.length / burst) * (NVD_WINDOW_MS / 1000));
217
224
  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
225
  const cachedCount = byId.size;
219
226
  const cachedHint = cachedCount ? `, ${cachedCount} cached` : "";
220
227
  console.log(`🔍 NVD: enriching ${liveIds.length} CVEs${cachedHint} — ${keyHint}. ETA ~${etaSec}s.`);
221
- } else if (uniqueCves.size) {
228
+ } else if (uniqueCves.size && !onProgress) {
222
229
  console.log(`🔍 NVD: ${uniqueCves.size} CVEs (all cached).`);
223
230
  }
224
231
 
@@ -229,6 +236,7 @@ async function enrichMatches(matches, opts = {}) {
229
236
  const startedAt = [];
230
237
  const startProgress = Date.now();
231
238
  const printProgress = (final = false) => {
239
+ if (onProgress) { onProgress(done, liveIds.length); return; }
232
240
  const elapsed = Math.round((Date.now() - startProgress) / 1000);
233
241
  const pct = liveIds.length ? Math.round((done / liveIds.length) * 100) : 100;
234
242
  const line = ` NVD: ${done}/${liveIds.length} (${pct}%) — ${elapsed}s elapsed`;
@@ -265,8 +273,11 @@ async function enrichMatches(matches, opts = {}) {
265
273
  }
266
274
  if (m.cve.severity === "UNKNOWN" || !m.cve.severity) m.cve.severity = data.severity;
267
275
  if (m.cve.score == null) m.cve.score = data.score;
268
- m.cve.cvssVector = data.cvssVector || null;
269
- m.cve.cvssVersion = data.cvssVersion || null;
276
+ // Only overwrite the vector/version when NVD actually has one — an NVD record
277
+ // without CVSS metrics must not clobber an OSV-derived vector/version (which is
278
+ // what feeds CSAF cvssV3Version + SBOM cvssMethod).
279
+ if (data.cvssVector) m.cve.cvssVector = data.cvssVector;
280
+ if (data.cvssVersion) m.cve.cvssVersion = data.cvssVersion;
270
281
  m.cve.nvdRefs = data.references || []; // [{url, tags, source}]
271
282
  m.cve.cpes = data.cpes || [];
272
283
  m.cve.cwes = data.cwes || [];
package/lib/osv.js CHANGED
@@ -12,6 +12,9 @@
12
12
  * GET /v1/vulns/{id} to fetch full details
13
13
  *
14
14
  * Cached responses live in ~/.fad-checker/osv-cache/ for 12h.
15
+ *
16
+ * @author: N.BRAUN
17
+ * @email: pp9ping@gmail.com
15
18
  */
16
19
  const fs = require("fs");
17
20
  const path = require("path");
@@ -50,31 +53,74 @@ function normalizeSeverity(s) {
50
53
  return SEVERITY_ALIASES[up] || up;
51
54
  }
52
55
 
53
- /** Map an OSV severity score string ("9.8", "CVSS:3.1/AV:N/.../I:H/A:H") to a level. */
54
- function severityFromOsv(vuln) {
55
- // Severity may live in `severity[]` (CVSS vector strings) or
56
- // `database_specific.severity` (GHSA-style: "CRITICAL", "HIGH", "MODERATE"...).
57
- const direct = vuln.database_specific?.severity;
58
- if (direct) return { severity: normalizeSeverity(direct), score: scoreFromVuln(vuln) };
59
- const sev = vuln.severity?.[0]?.score || "";
60
- const numeric = parseFloat(sev.match(/(\d+(\.\d+)?)/)?.[1]);
61
- if (Number.isFinite(numeric)) {
62
- if (numeric >= 9) return { severity: "CRITICAL", score: numeric };
63
- if (numeric >= 7) return { severity: "HIGH", score: numeric };
64
- if (numeric >= 4) return { severity: "MEDIUM", score: numeric };
65
- if (numeric > 0) return { severity: "LOW", score: numeric };
56
+ function severityFromScore(n) {
57
+ if (!Number.isFinite(n)) return "UNKNOWN";
58
+ if (n >= 9) return "CRITICAL";
59
+ if (n >= 7) return "HIGH";
60
+ if (n >= 4) return "MEDIUM";
61
+ if (n > 0) return "LOW";
62
+ return "NONE";
63
+ }
64
+
65
+ // Compute a CVSS v3.0/3.1 base score from its vector string. Returns null for
66
+ // non-v3 vectors (v2/v4) or anything unparseable. OSV stores the *vector* in
67
+ // `severity[].score` for CVSS_V3 there is NO bare base number to read, so the
68
+ // old "first number in the string" approach extracted the VERSION ("3.1") as the
69
+ // score, mislabelling HIGH findings as ~low everywhere downstream (SARIF
70
+ // security-severity, priority banding, SBOM/CSAF ratings).
71
+ function cvss3BaseScore(vector) {
72
+ const v = String(vector || "");
73
+ if (!/^CVSS:3\.[01]\//.test(v)) return null;
74
+ const p = {};
75
+ for (const kv of v.split("/").slice(1)) {
76
+ const [k, val] = kv.split(":");
77
+ if (k && val) p[k] = val;
66
78
  }
67
- return { severity: "UNKNOWN", score: null };
79
+ const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }[p.AV];
80
+ const AC = { L: 0.77, H: 0.44 }[p.AC];
81
+ const UI = { N: 0.85, R: 0.62 }[p.UI];
82
+ const scopeChanged = p.S === "C";
83
+ const PR = scopeChanged
84
+ ? { N: 0.85, L: 0.68, H: 0.5 }[p.PR]
85
+ : { N: 0.85, L: 0.62, H: 0.27 }[p.PR];
86
+ const imp = { H: 0.56, L: 0.22, N: 0 };
87
+ const C = imp[p.C], I = imp[p.I], A = imp[p.A];
88
+ if ([AV, AC, UI, PR, C, I, A].some(x => x == null)) return null;
89
+ const iss = 1 - (1 - C) * (1 - I) * (1 - A);
90
+ const impact = scopeChanged ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15) : 6.42 * iss;
91
+ if (impact <= 0) return 0;
92
+ const exploit = 8.22 * AV * AC * PR * UI;
93
+ const base = Math.min(scopeChanged ? 1.08 * (impact + exploit) : impact + exploit, 10);
94
+ return Math.ceil(base * 10) / 10; // CVSS roundup to one decimal
68
95
  }
69
96
 
70
- function scoreFromVuln(vuln) {
97
+ /** Best CVSS info from an OSV vuln: { score, vector, version } (version like "3.1"). */
98
+ function cvssInfoFromVuln(vuln) {
71
99
  for (const s of vuln.severity || []) {
72
- const m = String(s.score || "").match(/(\d+(\.\d+)?)/);
73
- if (m) return parseFloat(m[1]);
100
+ const str = String(s.score || "");
101
+ const mver = str.match(/^CVSS:(\d\.\d)\//);
102
+ if (mver) {
103
+ const version = mver[1];
104
+ const score = (version === "3.0" || version === "3.1") ? cvss3BaseScore(str) : null;
105
+ return { score, vector: str, version };
106
+ }
107
+ const mnum = str.match(/^\s*(\d+(\.\d+)?)\s*$/); // a bare numeric score ("9.8")
108
+ if (mnum) return { score: parseFloat(mnum[1]), vector: null, version: null };
74
109
  }
75
- return null;
110
+ return { score: null, vector: null, version: null };
76
111
  }
77
112
 
113
+ /** Map an OSV vuln to { severity, score }. */
114
+ function severityFromOsv(vuln) {
115
+ const direct = vuln.database_specific?.severity;
116
+ const { score } = cvssInfoFromVuln(vuln);
117
+ if (direct) return { severity: normalizeSeverity(direct), score };
118
+ if (score != null) return { severity: severityFromScore(score), score };
119
+ return { severity: "UNKNOWN", score: null };
120
+ }
121
+
122
+ function scoreFromVuln(vuln) { return cvssInfoFromVuln(vuln).score; }
123
+
78
124
  /** Extract the first fix version from OSV affected ranges (semver/ecosystem events). */
79
125
  function fixVersionFromOsv(vuln, depKey) {
80
126
  for (const a of vuln.affected || []) {
@@ -97,7 +143,7 @@ function pickPrimaryId(vuln) {
97
143
  }
98
144
 
99
145
  // 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" };
146
+ const OSV_ECO = { maven: "Maven", npm: "npm", yarn: "npm", nuget: "NuGet", composer: "Packagist", pypi: "PyPI", go: "Go", ruby: "RubyGems" };
101
147
 
102
148
  /** OSV ecosystem string for a dep, derived from its codec id. */
103
149
  function osvEcosystemFor(dep) { return OSV_ECO[dep.ecosystem] || "Maven"; }
@@ -115,6 +161,7 @@ function osvPkgName(dep) {
115
161
  function vulnToMatch(dep, vuln) {
116
162
  const id = pickPrimaryId(vuln);
117
163
  const { severity, score } = severityFromOsv(vuln);
164
+ const cvss = cvssInfoFromVuln(vuln);
118
165
  // `details` (long markdown) is the substantive description; `summary` is just the title.
119
166
  // We keep them both: summary as headline, details as body.
120
167
  const summary = (vuln.summary || "").trim();
@@ -137,6 +184,8 @@ function vulnToMatch(dep, vuln) {
137
184
  id,
138
185
  severity,
139
186
  score,
187
+ ...(cvss.vector ? { cvssVector: cvss.vector } : {}),
188
+ ...(cvss.version ? { cvssVersion: `CVSS:${cvss.version}` } : {}),
140
189
  description,
141
190
  summary,
142
191
  fixVersion: fixVersionFromOsv(vuln, osvPkgName(dep)),
@@ -152,7 +201,7 @@ function vulnToMatch(dep, vuln) {
152
201
  }
153
202
 
154
203
  async function queryBatch(deps, opts = {}) {
155
- const { verbose, offline, fetcher = globalThis.fetch } = opts;
204
+ const { verbose, offline, fetcher = globalThis.fetch, onProgress } = opts;
156
205
  // Build query list + parallel cached/uncached split
157
206
  const queries = [];
158
207
  const indexMap = []; // index in `deps` → either { cached: vulns } or { queryIdx: N }
@@ -187,7 +236,8 @@ async function queryBatch(deps, opts = {}) {
187
236
  let batchIdx = 0;
188
237
  for (const batch of batches) {
189
238
  batchIdx++;
190
- if (process.stdout.isTTY) process.stdout.write(`\r OSV batch ${batchIdx}/${batches.length} (${batch.length} deps)… `);
239
+ if (onProgress) onProgress(batchIdx, batches.length);
240
+ else if (process.stdout.isTTY) process.stdout.write(`\r OSV batch ${batchIdx}/${batches.length} (${batch.length} deps)… `);
191
241
  else if (batches.length > 1) console.log(` OSV batch ${batchIdx}/${batches.length} (${batch.length} deps)…`);
192
242
  const res = await fetcher(`${OSV_BASE}/v1/querybatch`, {
193
243
  method: "POST",
@@ -205,7 +255,8 @@ async function queryBatch(deps, opts = {}) {
205
255
  allResults[(batchIdx - 1) * BATCH_SIZE + j] = results[j] || { vulns: [] };
206
256
  }
207
257
  }
208
- if (process.stdout.isTTY) process.stdout.write(`\r OSV batches complete (${batches.length}) \n`);
258
+ if (onProgress) { /* finalized by caller */ }
259
+ else if (process.stdout.isTTY) process.stdout.write(`\r OSV batches complete (${batches.length}) \n`);
209
260
 
210
261
  // Persist per-dep cache (stub list — details cached separately)
211
262
  for (let i = 0; i < deps.length; i++) {
@@ -239,12 +290,13 @@ async function queryBatch(deps, opts = {}) {
239
290
  }
240
291
 
241
292
  if (liveIds.length) {
242
- console.log(` OSV details: ${liveIds.length} to fetch${allIds.size - liveIds.length ? `, ${allIds.size - liveIds.length} cached` : ""}…`);
293
+ if (!onProgress) console.log(` OSV details: ${liveIds.length} to fetch${allIds.size - liveIds.length ? `, ${allIds.size - liveIds.length} cached` : ""}…`);
243
294
  const concurrency = 10;
244
295
  let cursor = 0;
245
296
  let fetched = 0;
246
297
  const startedAt = Date.now();
247
298
  const printOsvProgress = (final = false) => {
299
+ if (onProgress) { onProgress(fetched, liveIds.length); return; }
248
300
  const elapsed = Math.round((Date.now() - startedAt) / 1000);
249
301
  const pct = Math.round((fetched / liveIds.length) * 100);
250
302
  const line = ` OSV details: ${fetched}/${liveIds.length} (${pct}%) — ${elapsed}s`;
package/lib/outdated.js CHANGED
@@ -4,6 +4,9 @@
4
4
  * EOL data comes from endoflife.date (with on-disk cache).
5
5
  * Obsolete data is curated locally (data/known-obsolete.json).
6
6
  * Outdated comes from Maven Central's maven-metadata.xml.
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
7
10
  */
8
11
  const fs = require("fs");
9
12
  const path = require("path");
@@ -251,7 +254,7 @@ function parseMavenMetadataLatest(xml) {
251
254
  }
252
255
 
253
256
  async function checkOutdatedDeps(resolvedDeps, opts = {}) {
254
- const { verbose, offline, concurrency = 8, repos } = opts;
257
+ const { verbose, offline, concurrency = 8, repos, onProgress } = opts;
255
258
  const cache = loadJsonCache(VERSION_CACHE_PATH);
256
259
  const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < VERSION_CACHE_MAX_AGE_MS;
257
260
  if (!fresh && !offline) cache.entries = {};
@@ -263,7 +266,7 @@ async function checkOutdatedDeps(resolvedDeps, opts = {}) {
263
266
  // seconds with 8-way concurrency, but on first run (cold cache) the user
264
267
  // would see total silence for 20-60s.
265
268
  const liveCount = offline ? 0 : list.filter(d => !cache.entries[`${d.groupId}:${d.artifactId}`]).length;
266
- if (liveCount && !offline) {
269
+ if (liveCount && !offline && !onProgress) {
267
270
  console.log(`📅 Outdated: checking ${list.length} deps against Maven Central (${liveCount} live, ${list.length - liveCount} cached)…`);
268
271
  }
269
272
 
@@ -271,6 +274,7 @@ async function checkOutdatedDeps(resolvedDeps, opts = {}) {
271
274
  let processed = 0;
272
275
  const startedAt = Date.now();
273
276
  const printOutdatedProgress = (final = false) => {
277
+ if (onProgress) { onProgress(processed, list.length); return; }
274
278
  if (!liveCount) return;
275
279
  const elapsed = Math.round((Date.now() - startedAt) / 1000);
276
280
  const pct = Math.round((processed / list.length) * 100);
@@ -308,6 +312,10 @@ module.exports = {
308
312
  checkObsolete,
309
313
  checkOutdatedDeps,
310
314
  findEolProduct,
315
+ findCycleForVersion,
316
+ isEol,
311
317
  isEolCacheFresh,
318
+ EOL_MAPPING,
319
+ EOL_CACHE_PATH,
312
320
  KNOWN_OBSOLETE,
313
321
  };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * lib/priority.js — composite prioritisation of a CVE match.
3
+ *
4
+ * Blends three independent signals the report already carries:
5
+ * - CVSS base score (severity of the flaw if exploited)
6
+ * - EPSS percentile (likelihood it WILL be exploited — lib/epss.js)
7
+ * - CISA KEV membership (it IS being exploited in the wild — lib/kev.js)
8
+ *
9
+ * KEV always wins (band "exploited", score floored at 90). Otherwise the score
10
+ * is an 80/20 blend of CVSS and EPSS percentile. Pure — no I/O.
11
+ *
12
+ * @author: N.BRAUN
13
+ * @email: pp9ping@gmail.com
14
+ */
15
+
16
+ const SEV_SCORE = { CRITICAL: 9.5, HIGH: 7.5, MEDIUM: 5, LOW: 2, NONE: 0, UNKNOWN: 0 };
17
+
18
+ function severityToScore(sev) {
19
+ return SEV_SCORE[(sev || "UNKNOWN").toUpperCase()] ?? 0;
20
+ }
21
+
22
+ function clamp(n, lo, hi) {
23
+ return Math.max(lo, Math.min(hi, n));
24
+ }
25
+
26
+ /**
27
+ * Compute a priority object for a cve sub-record.
28
+ * Returns { score (0-100), band, cvss, epssPercentile, kev, sortKey }.
29
+ */
30
+ function computePriority(cve = {}) {
31
+ // A literal 0 is treated as "no CVSS" and we fall back to the severity label:
32
+ // enrichers emit null for scoreless CVEs, so a 0 here is a placeholder, and a
33
+ // CRITICAL-labelled finding must not band as "low" just because score===0.
34
+ const cvss = (typeof cve.score === "number" && cve.score > 0) ? cve.score : severityToScore(cve.severity);
35
+ const epssKnown = typeof cve.epssPercentile === "number";
36
+ const epssPercentile = epssKnown ? clamp(cve.epssPercentile, 0, 1) : 0;
37
+ const kev = !!cve.kev;
38
+
39
+ // When EPSS is known, blend 80% CVSS + 20% exploit-likelihood (so a known-low
40
+ // EPSS slightly deprioritises a high-CVSS flaw). When EPSS is absent we don't
41
+ // dilute — a CVSS 9.8 with no EPSS data must still read as critical.
42
+ let score = epssKnown
43
+ ? clamp(cvss * 10 * 0.8 + epssPercentile * 100 * 0.2, 0, 100)
44
+ : clamp(cvss * 10, 0, 100);
45
+ let band;
46
+ if (kev) {
47
+ score = Math.max(score, 90);
48
+ band = "exploited";
49
+ } else if (score >= 90) band = "critical";
50
+ else if (score >= 70) band = "high";
51
+ else if (score >= 40) band = "medium";
52
+ else band = "low";
53
+
54
+ const rounded = Math.round(score * 10) / 10;
55
+ return {
56
+ score: rounded,
57
+ band,
58
+ cvss,
59
+ epssPercentile,
60
+ kev,
61
+ // Descending-sort tuple, aligned with the displayed score: exploited
62
+ // first, then the blended score (which already folds in CVSS + EPSS).
63
+ sortKey: [kev ? 1 : 0, rounded],
64
+ };
65
+ }
66
+
67
+ /** Attach m.cve.priority to every match in place. */
68
+ function attachPriority(matches) {
69
+ for (const m of matches || []) {
70
+ if (m && m.cve) m.cve.priority = computePriority(m.cve);
71
+ }
72
+ return matches;
73
+ }
74
+
75
+ /** Compare two matches descending by priority sortKey, then CVE id ascending. */
76
+ function comparePriority(a, b) {
77
+ const pa = a.cve?.priority || computePriority(a.cve);
78
+ const pb = b.cve?.priority || computePriority(b.cve);
79
+ for (let i = 0; i < pa.sortKey.length; i++) {
80
+ if (pb.sortKey[i] !== pa.sortKey[i]) return pb.sortKey[i] - pa.sortKey[i];
81
+ }
82
+ return (a.cve?.id || "").localeCompare(b.cve?.id || "");
83
+ }
84
+
85
+ /** Return a new array sorted descending by priority. */
86
+ function sortByPriority(matches) {
87
+ return [...(matches || [])].sort(comparePriority);
88
+ }
89
+
90
+ module.exports = { computePriority, attachPriority, comparePriority, sortByPriority, severityToScore };
package/lib/purl.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * lib/purl.js — build Package URL (purl) strings from a depRecord.
3
+ *
4
+ * purl spec: pkg:<type>/<namespace>/<name>@<version>?qualifiers#subpath
5
+ * We only emit type/namespace/name@version — enough for CycloneDX `bom-ref`
6
+ * and CSAF `product_identification_helper.purl`.
7
+ *
8
+ * Pure (no I/O). Shared by lib/sbom-export.js and lib/csaf-export.js.
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
12
+ */
13
+
14
+ // purl type per ecosystem. Maven keeps the dotted groupId as a single namespace
15
+ // segment (not slash-separated); npm/composer carry a namespace; pypi/nuget don't.
16
+ const TYPE = {
17
+ maven: "maven",
18
+ npm: "npm",
19
+ composer: "composer",
20
+ pypi: "pypi",
21
+ nuget: "nuget",
22
+ go: "golang",
23
+ ruby: "gem",
24
+ };
25
+
26
+ // Per the purl spec, each namespace segment and the name are percent-encoded,
27
+ // but the dots inside a Maven groupId and the separators are preserved by
28
+ // encodeURIComponent (it leaves "." "-" "_" "~" alone), so it is the right tool.
29
+ function enc(s) {
30
+ return encodeURIComponent(String(s));
31
+ }
32
+
33
+ // PEP 503 normalisation for PyPI names (purl mandates the canonical form).
34
+ function normalizePypi(name) {
35
+ return String(name).toLowerCase().replace(/[-_.]+/g, "-");
36
+ }
37
+
38
+ /**
39
+ * Build a purl for a depRecord. Returns null when the dep lacks a usable
40
+ * ecosystem/name. Version is omitted when absent.
41
+ */
42
+ function purlFor(dep) {
43
+ if (!dep || typeof dep !== "object") return null;
44
+ const eco = dep.ecosystem || dep.ecosystemType;
45
+ const type = TYPE[eco];
46
+ if (!type) return null;
47
+
48
+ const rawName = dep.name || dep.artifactId;
49
+ if (!rawName) return null;
50
+ let namespace = dep.namespace || dep.groupId || "";
51
+ let name = rawName;
52
+
53
+ if (eco === "npm" && !namespace && name.startsWith("@") && name.includes("/")) {
54
+ // Scoped package: "@scope/pkg" → namespace "@scope", name "pkg".
55
+ const slash = name.indexOf("/");
56
+ namespace = name.slice(0, slash);
57
+ name = name.slice(slash + 1);
58
+ } else if (eco === "pypi") {
59
+ name = normalizePypi(name);
60
+ namespace = "";
61
+ } else if (eco === "nuget") {
62
+ namespace = "";
63
+ } else if (eco === "go" && !namespace && name.includes("/")) {
64
+ // Go module path: namespace = everything up to the last "/", name = last segment.
65
+ const slash = name.lastIndexOf("/");
66
+ namespace = name.slice(0, slash);
67
+ name = name.slice(slash + 1);
68
+ }
69
+
70
+ // Namespace may itself be slash-separated (Go module paths); encode each
71
+ // segment but keep the separators.
72
+ const nsPart = namespace ? `${String(namespace).split("/").map(enc).join("/")}/` : "";
73
+ const verPart = dep.version ? `@${enc(dep.version)}` : "";
74
+ return `pkg:${type}/${nsPart}${enc(name)}${verPart}`;
75
+ }
76
+
77
+ module.exports = { purlFor };
package/lib/retire.js CHANGED
@@ -12,6 +12,9 @@
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`
14
14
  * on PATH as a fallback.
15
+ *
16
+ * @author: N.BRAUN
17
+ * @email: pp9ping@gmail.com
15
18
  */
16
19
  const fs = require("fs");
17
20
  const path = require("path");
@@ -24,6 +27,15 @@ const execFileP = promisify(execFile);
24
27
  const RETIRE_CACHE_DIR = path.join(os.homedir(), ".fad-checker", "retire-cache");
25
28
  const RETIRE_CACHE_TTL_MS = 24 * 3600 * 1000;
26
29
 
30
+ // retire's own signature DB. By default retire caches it in /tmp/.retire-cache
31
+ // (outside ~/.fad-checker/, with a 1h TTL → a network refetch on expiry). For the
32
+ // PASSI offline workflow we instead keep a stable local copy INSIDE ~/.fad-checker/
33
+ // so `--export-cache` carries it, and feed it to retire via `--jsrepo <file>`
34
+ // (loaded from file, never the network — no TTL).
35
+ const RETIRE_SIG_DIR = path.join(os.homedir(), ".fad-checker", "retire-signatures");
36
+ const RETIRE_SIG_FILE = path.join(RETIRE_SIG_DIR, "jsrepository-v5.json");
37
+ const RETIRE_REPO_URL = "https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository-v5.json";
38
+
27
39
  function cacheKey(srcDir) {
28
40
  return crypto.createHash("md5").update(path.resolve(srcDir)).digest("hex") + ".json";
29
41
  }
@@ -50,6 +62,54 @@ function findRetireBin() {
50
62
  return "retire"; // fall back to PATH
51
63
  }
52
64
 
65
+ /**
66
+ * Fetch retire's signature DB to a stable file inside ~/.fad-checker/ so it can
67
+ * be bundled by --export-cache and reused offline via --jsrepo. Network call —
68
+ * online only. Returns { ok, path } | { ok:false, error }. Never throws.
69
+ */
70
+ async function warmRetireSignatures(opts = {}) {
71
+ const { verbose, force } = opts;
72
+ if (!force && fs.existsSync(RETIRE_SIG_FILE)) {
73
+ if (verbose) console.log(`retire: signatures already present (${RETIRE_SIG_FILE})`);
74
+ return { ok: true, path: RETIRE_SIG_FILE, cached: true };
75
+ }
76
+ try {
77
+ const res = await fetch(RETIRE_REPO_URL);
78
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
79
+ const text = await res.text();
80
+ JSON.parse(text); // validate it's the repo JSON before persisting
81
+ fs.mkdirSync(RETIRE_SIG_DIR, { recursive: true });
82
+ fs.writeFileSync(RETIRE_SIG_FILE, text);
83
+ if (verbose) console.log(`retire: signatures warmed → ${RETIRE_SIG_FILE}`);
84
+ return { ok: true, path: RETIRE_SIG_FILE };
85
+ } catch (e) {
86
+ if (verbose) console.warn(`retire: signature warm failed — ${e.message}`);
87
+ return { ok: false, error: e.message };
88
+ }
89
+ }
90
+
91
+ // Ensure a local signature file exists. Online: fetch it if missing. Offline:
92
+ // only report whether it's already there (no network).
93
+ async function ensureSignatures({ verbose, offline } = {}) {
94
+ if (fs.existsSync(RETIRE_SIG_FILE)) return true;
95
+ if (offline) return false;
96
+ const r = await warmRetireSignatures({ verbose });
97
+ return !!r.ok;
98
+ }
99
+
100
+ // Pure: build the retire CLI argv. `jsRepo` (a local signature file) is added
101
+ // when available so retire loads signatures from disk instead of the network.
102
+ function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
103
+ const args = [
104
+ "--outputformat", "json",
105
+ "--outputpath", outPath,
106
+ "--jspath", srcDir,
107
+ "--ignore", ignoredDirs,
108
+ ];
109
+ if (jsRepo) { args.push("--jsrepo", jsRepo); }
110
+ return args;
111
+ }
112
+
53
113
  /**
54
114
  * Run retire.js against `srcDir`. Returns the parsed JSON output (an array
55
115
  * of file-level findings) or null if retire is unavailable.
@@ -59,12 +119,9 @@ function findRetireBin() {
59
119
  */
60
120
  async function runRetire(srcDir, opts = {}) {
61
121
  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
- }
122
+ // No source tree (e.g. --import-anonymized) → nothing to scan for vendored JS.
123
+ if (!srcDir) { if (verbose) console.warn("retire: no source dir — skipped"); return null; }
124
+ // Findings-cache fast path (path-keyed). Works online and offline.
68
125
  if (!force) {
69
126
  const cached = readCache(srcDir);
70
127
  if (cached) {
@@ -73,6 +130,15 @@ async function runRetire(srcDir, opts = {}) {
73
130
  }
74
131
  }
75
132
 
133
+ // No findings cache → we must scan. We need a signature DB. Offline, we can
134
+ // only proceed if a local signature file was previously warmed (and bundled
135
+ // via --export-cache / --import-cache); otherwise honor --offline and skip.
136
+ const haveSig = await ensureSignatures({ verbose, offline });
137
+ if (offline && !haveSig) {
138
+ if (verbose) console.warn("retire: --offline, no findings cache and no local signatures — skipped");
139
+ return null;
140
+ }
141
+
76
142
  const bin = findRetireBin();
77
143
  const ignoredDirs = [
78
144
  "node_modules", "bower_components", "jspm_packages",
@@ -83,13 +149,8 @@ async function runRetire(srcDir, opts = {}) {
83
149
  // retire.js refuses to write to /dev/stdout, so we use a real temp file
84
150
  // and read it back. Falls back to stdout if --outputpath is rejected.
85
151
  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}…`);
152
+ const args = buildRetireArgs({ srcDir, outPath: tmpOut, ignoredDirs, jsRepo: haveSig ? RETIRE_SIG_FILE : null });
153
+ if (verbose) console.log(`retire: scanning ${srcDir}…${haveSig ? " (local signatures)" : ""}`);
93
154
 
94
155
  try {
95
156
  // retire.js exits with code 13 when it finds vulnerabilities — that's
@@ -207,5 +268,11 @@ module.exports = {
207
268
  runRetire,
208
269
  normaliseRetireResults,
209
270
  findRetireBin,
271
+ warmRetireSignatures,
272
+ ensureSignatures,
273
+ buildRetireArgs,
210
274
  RETIRE_CACHE_DIR,
275
+ RETIRE_SIG_DIR,
276
+ RETIRE_SIG_FILE,
277
+ RETIRE_REPO_URL,
211
278
  };