fad-checker 1.0.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.
- package/CLAUDE.md +126 -0
- package/README.md +279 -0
- package/completions/fad-check.bash +23 -0
- package/completions/fad-check.zsh +27 -0
- package/data/cpe-coord-map.json +112 -0
- package/data/eol-mapping.json +34 -0
- package/data/known-obsolete.json +142 -0
- package/data/known-public-namespaces.json +79 -0
- package/docs/ARCHITECTURE.md +152 -0
- package/docs/USAGE.md +179 -0
- package/fad-check.js +665 -0
- package/lib/cache-archive.js +107 -0
- package/lib/config.js +87 -0
- package/lib/core.js +317 -0
- package/lib/cpe.js +287 -0
- package/lib/cve-download.js +369 -0
- package/lib/cve-match.js +228 -0
- package/lib/cve-report.js +1455 -0
- package/lib/maven-repo.js +134 -0
- package/lib/maven-version.js +153 -0
- package/lib/npm/collect.js +224 -0
- package/lib/npm/parse.js +291 -0
- package/lib/nvd.js +239 -0
- package/lib/osv.js +298 -0
- package/lib/outdated.js +265 -0
- package/lib/retire.js +211 -0
- package/lib/scan-completeness.js +67 -0
- package/lib/snyk.js +127 -0
- package/lib/transitive.js +410 -0
- package/package.json +35 -0
- package/test/core.test.js +153 -0
- package/test/cpe.test.js +148 -0
- package/test/cve-download.test.js +39 -0
- package/test/cve-match.test.js +108 -0
- package/test/cve-report.test.js +79 -0
- package/test/fixtures/complex-enterprise/api/pom.xml +32 -0
- package/test/fixtures/complex-enterprise/build/pom.xml +46 -0
- package/test/fixtures/complex-enterprise/dao/pom.xml +37 -0
- package/test/fixtures/complex-enterprise/pom.xml +66 -0
- package/test/fixtures/complex-enterprise/web/pom.xml +77 -0
- package/test/fixtures/cve-samples/cve-non-java.json +19 -0
- package/test/fixtures/cve-samples/cve-product-only.json +31 -0
- package/test/fixtures/cve-samples/cve-with-packagename.json +37 -0
- package/test/fixtures/cve-samples/nvd-log4shell.json +40 -0
- package/test/fixtures/cve-samples/nvd-npm-lodash.json +22 -0
- package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +26 -0
- package/test/fixtures/monorepo-mixed/packages/cli/package.json +14 -0
- package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +41 -0
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +9 -0
- package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +71 -0
- package/test/fixtures/monorepo-mixed/packages/web-app/package.json +17 -0
- package/test/fixtures/monorepo-mixed/pom.xml +29 -0
- package/test/fixtures/monorepo-mixed/services/api/pom.xml +27 -0
- package/test/fixtures/monorepo-mixed/services/worker/pom.xml +28 -0
- package/test/fixtures/private-lib-detection/core/pom.xml +26 -0
- package/test/fixtures/private-lib-detection/plugin/pom.xml +23 -0
- package/test/fixtures/private-lib-detection/pom.xml +35 -0
- package/test/fixtures/simple/app/pom.xml +28 -0
- package/test/fixtures/simple/lib/pom.xml +18 -0
- package/test/fixtures/simple/pom.xml +24 -0
- package/test/maven-repo.test.js +111 -0
- package/test/maven-version.test.js +48 -0
- package/test/monorepo.test.js +132 -0
- package/test/npm.test.js +146 -0
- package/test/outdated.test.js +56 -0
- package/test/snyk.test.js +64 -0
- package/test/transitive.test.js +305 -0
package/lib/osv.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/osv.js — query OSV.dev for Maven-ecosystem vulnerabilities.
|
|
3
|
+
*
|
|
4
|
+
* OSV.dev (Google + GitHub Security Lab) aggregates CVE + GHSA data and
|
|
5
|
+
* maintains a curated Maven-coordinate mapping. For Maven recall this is
|
|
6
|
+
* vastly better than the raw CVEProject feed (which lacks packageName
|
|
7
|
+
* fields on pre-2024 CVEs).
|
|
8
|
+
*
|
|
9
|
+
* API: https://google.github.io/osv.dev/post-v1-querybatch/
|
|
10
|
+
* POST /v1/querybatch with up to 1000 queries
|
|
11
|
+
* POST /v1/query for a single dep
|
|
12
|
+
* GET /v1/vulns/{id} to fetch full details
|
|
13
|
+
*
|
|
14
|
+
* Cached responses live in ~/.fad-check/osv-cache/ for 12h.
|
|
15
|
+
*/
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const os = require("os");
|
|
19
|
+
|
|
20
|
+
const OSV_CACHE_DIR = path.join(os.homedir(), ".fad-check", "osv-cache");
|
|
21
|
+
const OSV_CACHE_TTL_MS = 12 * 3600 * 1000;
|
|
22
|
+
const OSV_BASE = "https://api.osv.dev";
|
|
23
|
+
const BATCH_SIZE = 800; // OSV limit is 1000; stay under for safety
|
|
24
|
+
|
|
25
|
+
function cacheKey(g, a, v, ecosystem = "maven") {
|
|
26
|
+
const safeG = (g || "").replace(/[/\\]/g, "_");
|
|
27
|
+
const safeA = (a || "").replace(/[/\\@]/g, "_");
|
|
28
|
+
return `${ecosystem}__${safeG}__${safeA}__${v}.json`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readCache(name) {
|
|
32
|
+
const p = path.join(OSV_CACHE_DIR, name);
|
|
33
|
+
if (!fs.existsSync(p)) return null;
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
36
|
+
if (Date.now() - data._fetchedAt < OSV_CACHE_TTL_MS) return data.body;
|
|
37
|
+
} catch { /* ignore */ }
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeCache(name, body) {
|
|
42
|
+
fs.mkdirSync(OSV_CACHE_DIR, { recursive: true });
|
|
43
|
+
fs.writeFileSync(path.join(OSV_CACHE_DIR, name), JSON.stringify({ _fetchedAt: Date.now(), body }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SEVERITY_ALIASES = { MODERATE: "MEDIUM", IMPORTANT: "HIGH", SEVERE: "HIGH", INFO: "LOW" };
|
|
47
|
+
|
|
48
|
+
function normalizeSeverity(s) {
|
|
49
|
+
const up = String(s || "").toUpperCase();
|
|
50
|
+
return SEVERITY_ALIASES[up] || up;
|
|
51
|
+
}
|
|
52
|
+
|
|
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 };
|
|
66
|
+
}
|
|
67
|
+
return { severity: "UNKNOWN", score: null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function scoreFromVuln(vuln) {
|
|
71
|
+
for (const s of vuln.severity || []) {
|
|
72
|
+
const m = String(s.score || "").match(/(\d+(\.\d+)?)/);
|
|
73
|
+
if (m) return parseFloat(m[1]);
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Extract the first fix version from OSV affected ranges (semver/ecosystem events). */
|
|
79
|
+
function fixVersionFromOsv(vuln, depKey) {
|
|
80
|
+
for (const a of vuln.affected || []) {
|
|
81
|
+
const name = a.package?.name?.toLowerCase();
|
|
82
|
+
if (name && name !== depKey.toLowerCase()) continue;
|
|
83
|
+
for (const r of a.ranges || []) {
|
|
84
|
+
for (const ev of r.events || []) {
|
|
85
|
+
if (ev.fixed) return ev.fixed;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Pick the best CVE id from an OSV vuln (prefer CVE-* aliases over GHSA-*). */
|
|
93
|
+
function pickPrimaryId(vuln) {
|
|
94
|
+
if (vuln.id?.startsWith("CVE-")) return vuln.id;
|
|
95
|
+
const cveAlias = (vuln.aliases || []).find(a => a.startsWith("CVE-"));
|
|
96
|
+
return cveAlias || vuln.id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Build the OSV package-name key for a dep (Maven uses g:a, npm uses bare name). */
|
|
100
|
+
function osvPkgName(dep) {
|
|
101
|
+
return dep.ecosystem === "npm" ? dep.artifactId : `${dep.groupId}:${dep.artifactId}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Convert one OSV vuln to fad-check match shape. */
|
|
105
|
+
function vulnToMatch(dep, vuln) {
|
|
106
|
+
const id = pickPrimaryId(vuln);
|
|
107
|
+
const { severity, score } = severityFromOsv(vuln);
|
|
108
|
+
// `details` (long markdown) is the substantive description; `summary` is just the title.
|
|
109
|
+
// We keep them both: summary as headline, details as body.
|
|
110
|
+
const summary = (vuln.summary || "").trim();
|
|
111
|
+
const details = (vuln.details || "").trim();
|
|
112
|
+
let description = "";
|
|
113
|
+
if (summary && details && !details.toLowerCase().startsWith(summary.toLowerCase())) {
|
|
114
|
+
description = `${summary}\n\n${details}`;
|
|
115
|
+
} else if (details) {
|
|
116
|
+
description = details;
|
|
117
|
+
} else {
|
|
118
|
+
description = summary;
|
|
119
|
+
}
|
|
120
|
+
// Keep up to 2000 chars — enough for full advisory text without bloating the report.
|
|
121
|
+
if (description.length > 2000) description = description.slice(0, 2000) + "…";
|
|
122
|
+
// Categorise OSV references: ADVISORY / FIX / REPORT / WEB / PACKAGE / ARTICLE / EVIDENCE
|
|
123
|
+
const refs = (vuln.references || []).map(r => ({ type: String(r.type || "WEB").toUpperCase(), url: r.url }));
|
|
124
|
+
return {
|
|
125
|
+
dep,
|
|
126
|
+
cve: {
|
|
127
|
+
id,
|
|
128
|
+
severity,
|
|
129
|
+
score,
|
|
130
|
+
description,
|
|
131
|
+
summary,
|
|
132
|
+
fixVersion: fixVersionFromOsv(vuln, osvPkgName(dep)),
|
|
133
|
+
ghsa: (vuln.aliases || []).find(a => a.startsWith("GHSA-")) || (vuln.id?.startsWith("GHSA-") ? vuln.id : null),
|
|
134
|
+
published: vuln.published || null,
|
|
135
|
+
modified: vuln.modified || null,
|
|
136
|
+
osvRefs: refs,
|
|
137
|
+
aliases: vuln.aliases || [],
|
|
138
|
+
},
|
|
139
|
+
source: "osv",
|
|
140
|
+
confidence: "exact",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function queryBatch(deps, opts = {}) {
|
|
145
|
+
const { verbose, offline, fetcher = globalThis.fetch } = opts;
|
|
146
|
+
// Build query list + parallel cached/uncached split
|
|
147
|
+
const queries = [];
|
|
148
|
+
const indexMap = []; // index in `deps` → either { cached: vulns } or { queryIdx: N }
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < deps.length; i++) {
|
|
151
|
+
const d = deps[i];
|
|
152
|
+
const ck = cacheKey(d.groupId, d.artifactId, d.version, d.ecosystem || "maven");
|
|
153
|
+
const hit = readCache(ck);
|
|
154
|
+
if (hit !== null) {
|
|
155
|
+
indexMap[i] = { cached: hit };
|
|
156
|
+
} else {
|
|
157
|
+
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}`;
|
|
163
|
+
queries.push({
|
|
164
|
+
package: { name: pkgName, ecosystem },
|
|
165
|
+
version: d.version,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Run batch queries for uncached deps (if any). In offline mode we skip
|
|
171
|
+
// the network and rely entirely on cached per-dep ID lists.
|
|
172
|
+
const allResults = new Array(queries.length);
|
|
173
|
+
if (queries.length && !offline) {
|
|
174
|
+
const batches = [];
|
|
175
|
+
for (let i = 0; i < queries.length; i += BATCH_SIZE) {
|
|
176
|
+
batches.push(queries.slice(i, i + BATCH_SIZE));
|
|
177
|
+
}
|
|
178
|
+
let batchIdx = 0;
|
|
179
|
+
for (const batch of batches) {
|
|
180
|
+
if (verbose) process.stdout.write(`\r OSV batch ${++batchIdx}/${batches.length} (${batch.length} deps)…`);
|
|
181
|
+
const res = await fetcher(`${OSV_BASE}/v1/querybatch`, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/json", "User-Agent": "fad-check-osv" },
|
|
184
|
+
body: JSON.stringify({ queries: batch }),
|
|
185
|
+
});
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
if (verbose) console.warn(`\n OSV HTTP ${res.status}`);
|
|
188
|
+
for (let j = 0; j < batch.length; j++) allResults[(batchIdx - 1) * BATCH_SIZE + j] = { vulns: [] };
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const data = await res.json();
|
|
192
|
+
const results = data.results || [];
|
|
193
|
+
for (let j = 0; j < batch.length; j++) {
|
|
194
|
+
allResults[(batchIdx - 1) * BATCH_SIZE + j] = results[j] || { vulns: [] };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (verbose) process.stdout.write(`\r OSV batches complete (${batches.length}) \n`);
|
|
198
|
+
|
|
199
|
+
// Persist per-dep cache (stub list — details cached separately)
|
|
200
|
+
for (let i = 0; i < deps.length; i++) {
|
|
201
|
+
const slot = indexMap[i];
|
|
202
|
+
if (slot.queryIdx != null) {
|
|
203
|
+
const queryGlobalIdx = slot.queryIdx;
|
|
204
|
+
const result = allResults[queryGlobalIdx] || { vulns: [] };
|
|
205
|
+
const ids = (result.vulns || []).map(v => v.id);
|
|
206
|
+
writeCache(slot.cacheKey, ids);
|
|
207
|
+
slot.cached = ids;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Collect every unique vuln id we need to enrich — whether it came from
|
|
213
|
+
// the cached per-dep list or from a fresh batch result.
|
|
214
|
+
const allIds = new Set();
|
|
215
|
+
for (const slot of indexMap) for (const id of (slot.cached || [])) allIds.add(id);
|
|
216
|
+
|
|
217
|
+
const detailById = new Map();
|
|
218
|
+
let fetched = 0;
|
|
219
|
+
for (const id of allIds) {
|
|
220
|
+
const detailCacheKey = `vuln_${id}.json`;
|
|
221
|
+
const hit = readCache(detailCacheKey);
|
|
222
|
+
if (hit) { detailById.set(id, hit); continue; }
|
|
223
|
+
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);
|
|
232
|
+
}
|
|
233
|
+
} catch { /* ignore individual failures */ }
|
|
234
|
+
fetched++;
|
|
235
|
+
if (verbose && fetched % 25 === 0) process.stdout.write(`\r OSV details fetched: ${fetched}/${allIds.size}`);
|
|
236
|
+
}
|
|
237
|
+
if (verbose) process.stdout.write(`\r OSV details fetched: ${fetched}/${allIds.size} \n`);
|
|
238
|
+
|
|
239
|
+
return runMatches(deps, indexMap, detailById);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function runMatches(deps, indexMap, detailById) {
|
|
243
|
+
const matches = [];
|
|
244
|
+
for (let i = 0; i < deps.length; i++) {
|
|
245
|
+
const slot = indexMap[i];
|
|
246
|
+
const ids = slot.cached || [];
|
|
247
|
+
const dep = deps[i];
|
|
248
|
+
for (const id of ids) {
|
|
249
|
+
const vuln = detailById instanceof Map ? detailById.get(id) : null;
|
|
250
|
+
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
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
matches.push(vulnToMatch(dep, vuln));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return matches;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Public: query OSV for every dep in `resolvedDeps` Map, return fad-check-shape matches.
|
|
269
|
+
*/
|
|
270
|
+
async function queryOsvForDeps(resolvedDeps, opts = {}) {
|
|
271
|
+
const deps = [];
|
|
272
|
+
for (const d of resolvedDeps.values()) {
|
|
273
|
+
if (!d.version || /\$\{|SNAPSHOT/i.test(d.version)) continue;
|
|
274
|
+
if (d.scope === "parent") continue; // parents don't have OSV entries typically
|
|
275
|
+
// npm version specifiers like "^1.0.0" can't be queried against OSV — need
|
|
276
|
+
// a concrete version. Lockfiles always provide one; package.json-only deps
|
|
277
|
+
// are skipped here.
|
|
278
|
+
if (d.ecosystem === "npm" && /^[\^~>=<*]|^(latest|next|workspace:|git\+|file:|link:)/.test(d.version)) continue;
|
|
279
|
+
deps.push({
|
|
280
|
+
groupId: d.groupId, artifactId: d.artifactId, version: d.version,
|
|
281
|
+
scope: d.scope, via: d.via, depth: d.depth, pomPaths: d.pomPaths,
|
|
282
|
+
manifestPaths: d.manifestPaths,
|
|
283
|
+
ecosystem: d.ecosystem || "maven",
|
|
284
|
+
ecosystemType: d.ecosystemType,
|
|
285
|
+
isDev: !!d.isDev,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return queryBatch(deps, opts);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = {
|
|
292
|
+
queryOsvForDeps,
|
|
293
|
+
queryBatch,
|
|
294
|
+
vulnToMatch,
|
|
295
|
+
severityFromOsv,
|
|
296
|
+
fixVersionFromOsv,
|
|
297
|
+
OSV_CACHE_DIR,
|
|
298
|
+
};
|
package/lib/outdated.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/outdated.js — EOL, obsolete, and outdated dependency checks.
|
|
3
|
+
*
|
|
4
|
+
* EOL data comes from endoflife.date (with on-disk cache).
|
|
5
|
+
* Obsolete data is curated locally (data/known-obsolete.json).
|
|
6
|
+
* Outdated comes from Maven Central's maven-metadata.xml.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
const { compareMavenVersions } = require("./maven-version");
|
|
12
|
+
|
|
13
|
+
const KNOWN_OBSOLETE = require("../data/known-obsolete.json");
|
|
14
|
+
const EOL_MAPPING = require("../data/eol-mapping.json");
|
|
15
|
+
|
|
16
|
+
const CACHE_DIR = path.join(os.homedir(), ".fad-check");
|
|
17
|
+
const EOL_CACHE_PATH = path.join(CACHE_DIR, "eol-cache.json");
|
|
18
|
+
const VERSION_CACHE_PATH = path.join(CACHE_DIR, "version-cache.json");
|
|
19
|
+
const EOL_CACHE_MAX_AGE_MS = 7 * 24 * 3600 * 1000; // 7 days
|
|
20
|
+
const VERSION_CACHE_MAX_AGE_MS = 24 * 3600 * 1000; // 1 day
|
|
21
|
+
|
|
22
|
+
function loadJsonCache(file) {
|
|
23
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
24
|
+
catch { return { meta: { fetchedAt: 0 }, entries: {} }; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function saveJsonCache(file, data) {
|
|
28
|
+
try {
|
|
29
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
30
|
+
fs.writeFileSync(file, JSON.stringify(data));
|
|
31
|
+
} catch { /* ignore */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isEolCacheFresh(maxAge = EOL_CACHE_MAX_AGE_MS) {
|
|
35
|
+
const c = loadJsonCache(EOL_CACHE_PATH);
|
|
36
|
+
return c.meta?.fetchedAt && (Date.now() - c.meta.fetchedAt) < maxAge;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// -------- EOL via endoflife.date --------
|
|
40
|
+
|
|
41
|
+
function findEolProduct(dep) {
|
|
42
|
+
const key = `${dep.groupId}:${dep.artifactId}`;
|
|
43
|
+
const direct = EOL_MAPPING.by_group_artifact?.[key];
|
|
44
|
+
if (direct) return direct;
|
|
45
|
+
// Match longest groupId prefix
|
|
46
|
+
const prefixes = Object.keys(EOL_MAPPING.by_group_prefix || {})
|
|
47
|
+
.sort((a, b) => b.length - a.length);
|
|
48
|
+
for (const p of prefixes) {
|
|
49
|
+
if (dep.groupId === p || dep.groupId.startsWith(p + ".")) {
|
|
50
|
+
return EOL_MAPPING.by_group_prefix[p];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function fetchEndoflife(product, cache, opts = {}) {
|
|
57
|
+
if (cache.entries[product]) return cache.entries[product];
|
|
58
|
+
if (opts.offline) return null;
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(`https://endoflife.date/api/${encodeURIComponent(product)}.json`, {
|
|
61
|
+
headers: { "User-Agent": "fad-check-eol-checker" },
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
cache.entries[product] = { error: `HTTP ${res.status}` };
|
|
65
|
+
return cache.entries[product];
|
|
66
|
+
}
|
|
67
|
+
const cycles = await res.json();
|
|
68
|
+
cache.entries[product] = cycles;
|
|
69
|
+
return cycles;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
cache.entries[product] = { error: err.message };
|
|
72
|
+
return cache.entries[product];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function findCycleForVersion(cycles, version) {
|
|
77
|
+
if (!Array.isArray(cycles) || !version) return null;
|
|
78
|
+
// Match by cycle prefix: e.g. version "2.7.18" → cycle "2.7"
|
|
79
|
+
for (const c of cycles) {
|
|
80
|
+
const cycle = String(c.cycle || "");
|
|
81
|
+
if (!cycle) continue;
|
|
82
|
+
if (version === cycle || version.startsWith(cycle + ".") || version.startsWith(cycle + "-")) {
|
|
83
|
+
return c;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isEol(cycle) {
|
|
90
|
+
if (!cycle) return false;
|
|
91
|
+
if (cycle.eol === true) return true;
|
|
92
|
+
if (typeof cycle.eol === "string") {
|
|
93
|
+
const eolDate = new Date(cycle.eol);
|
|
94
|
+
if (!isNaN(eolDate.getTime())) return eolDate.getTime() < Date.now();
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function checkEolDeps(resolvedDeps, opts = {}) {
|
|
100
|
+
const { verbose, offline } = opts;
|
|
101
|
+
const cache = loadJsonCache(EOL_CACHE_PATH);
|
|
102
|
+
const fresh = isEolCacheFresh();
|
|
103
|
+
const results = [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
|
|
106
|
+
for (const dep of resolvedDeps.values()) {
|
|
107
|
+
if (dep.ecosystem === "npm") continue; // EOL mapping is Maven-only for now
|
|
108
|
+
const product = findEolProduct(dep);
|
|
109
|
+
if (!product) continue;
|
|
110
|
+
const dedupeKey = `${dep.groupId}:${dep.artifactId}|${product.product}`;
|
|
111
|
+
if (seen.has(dedupeKey)) continue;
|
|
112
|
+
seen.add(dedupeKey);
|
|
113
|
+
|
|
114
|
+
const cycles = fresh && cache.entries[product.product]
|
|
115
|
+
? cache.entries[product.product]
|
|
116
|
+
: await fetchEndoflife(product.product, cache, { offline });
|
|
117
|
+
|
|
118
|
+
if (!Array.isArray(cycles)) continue;
|
|
119
|
+
const cycle = findCycleForVersion(cycles, dep.version);
|
|
120
|
+
if (!cycle) continue;
|
|
121
|
+
if (!isEol(cycle)) continue;
|
|
122
|
+
|
|
123
|
+
results.push({
|
|
124
|
+
dep,
|
|
125
|
+
product: product.label || product.product,
|
|
126
|
+
productSlug: product.product,
|
|
127
|
+
cycle: cycle.cycle,
|
|
128
|
+
eol: cycle.eol === true ? "true" : String(cycle.eol),
|
|
129
|
+
latest: cycle.latest || null,
|
|
130
|
+
notes: cycle.latestReleaseDate ? `Latest: ${cycle.latest} (${cycle.latestReleaseDate})` : "",
|
|
131
|
+
});
|
|
132
|
+
if (verbose) process.stdout.write(` EOL: ${dep.groupId}:${dep.artifactId}:${dep.version} (${product.label})\n`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
cache.meta = { fetchedAt: Date.now() };
|
|
136
|
+
saveJsonCache(EOL_CACHE_PATH, cache);
|
|
137
|
+
return results;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// -------- Obsolete via curated list --------
|
|
141
|
+
|
|
142
|
+
function checkObsoleteDeps(resolvedDeps) {
|
|
143
|
+
const results = [];
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
for (const dep of resolvedDeps.values()) {
|
|
146
|
+
const key = `${dep.groupId}:${dep.artifactId}`;
|
|
147
|
+
if (seen.has(key)) continue;
|
|
148
|
+
const entry = KNOWN_OBSOLETE[key];
|
|
149
|
+
if (!entry) continue;
|
|
150
|
+
seen.add(key);
|
|
151
|
+
results.push({
|
|
152
|
+
dep,
|
|
153
|
+
severity: entry.severity || "MEDIUM",
|
|
154
|
+
replacement: entry.replacement || null,
|
|
155
|
+
reason: entry.reason || "",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return results;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function checkObsolete(dep) {
|
|
162
|
+
const entry = KNOWN_OBSOLETE[`${dep.groupId}:${dep.artifactId}`];
|
|
163
|
+
return entry ? { dep, ...entry } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// -------- Outdated via Maven Central --------
|
|
167
|
+
|
|
168
|
+
async function fetchLatestVersion(groupId, artifactId, cache, opts = {}) {
|
|
169
|
+
const key = `${groupId}:${artifactId}`;
|
|
170
|
+
if (cache.entries[key]) return cache.entries[key];
|
|
171
|
+
if (opts.offline) return null;
|
|
172
|
+
// Try Central's Solr first (fast), then fall back to maven-metadata.xml
|
|
173
|
+
// in every configured private repo. The private fallback is what catches
|
|
174
|
+
// internal Nexus/Artifactory artifacts that aren't on Central.
|
|
175
|
+
try {
|
|
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" } });
|
|
178
|
+
if (res.ok) {
|
|
179
|
+
const json = await res.json();
|
|
180
|
+
const doc = json?.response?.docs?.[0];
|
|
181
|
+
if (doc) {
|
|
182
|
+
const entry = { latest: doc.v, releaseDate: doc.timestamp ? new Date(doc.timestamp).toISOString().slice(0, 10) : null, source: "central" };
|
|
183
|
+
cache.entries[key] = entry;
|
|
184
|
+
return entry;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch { /* fall through to repo metadata */ }
|
|
188
|
+
|
|
189
|
+
if (Array.isArray(opts.repos) && opts.repos.length) {
|
|
190
|
+
try {
|
|
191
|
+
const { fetchMavenMetadata } = require("./maven-repo");
|
|
192
|
+
const hit = await fetchMavenMetadata(opts.repos, groupId, artifactId, { userAgent: "fad-check-outdated-checker" });
|
|
193
|
+
if (hit?.body) {
|
|
194
|
+
const latest = parseMavenMetadataLatest(hit.body);
|
|
195
|
+
if (latest) {
|
|
196
|
+
const entry = { latest, releaseDate: null, source: hit.repo.name || hit.repo.url };
|
|
197
|
+
cache.entries[key] = entry;
|
|
198
|
+
return entry;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch { /* swallow */ }
|
|
202
|
+
}
|
|
203
|
+
cache.entries[key] = { error: "not found" };
|
|
204
|
+
return cache.entries[key];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Pull the "latest" version from a maven-metadata.xml body. Prefers
|
|
209
|
+
* <versioning><release> (stable), falls back to <versioning><latest> and
|
|
210
|
+
* then the highest entry in <versioning><versions>.
|
|
211
|
+
*/
|
|
212
|
+
function parseMavenMetadataLatest(xml) {
|
|
213
|
+
if (!xml) return null;
|
|
214
|
+
const release = xml.match(/<release>([^<]+)<\/release>/);
|
|
215
|
+
if (release?.[1]) return release[1].trim();
|
|
216
|
+
const latest = xml.match(/<latest>([^<]+)<\/latest>/);
|
|
217
|
+
if (latest?.[1]) return latest[1].trim();
|
|
218
|
+
const versions = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map(m => m[1].trim()).filter(Boolean);
|
|
219
|
+
if (versions.length) {
|
|
220
|
+
try { versions.sort((a, b) => compareMavenVersions(a, b)); return versions[versions.length - 1]; }
|
|
221
|
+
catch { return versions[versions.length - 1]; }
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function checkOutdatedDeps(resolvedDeps, opts = {}) {
|
|
227
|
+
const { verbose, offline, concurrency = 8, repos } = opts;
|
|
228
|
+
const cache = loadJsonCache(VERSION_CACHE_PATH);
|
|
229
|
+
const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < VERSION_CACHE_MAX_AGE_MS;
|
|
230
|
+
if (!fresh && !offline) cache.entries = {};
|
|
231
|
+
const list = [...resolvedDeps.values()].filter(d => d.version && !/\$\{|SNAPSHOT/i.test(d.version) && d.ecosystem !== "npm");
|
|
232
|
+
const results = [];
|
|
233
|
+
|
|
234
|
+
// Simple p-limit style throttle without requiring p-limit here (already used in fad-check.js)
|
|
235
|
+
let cursor = 0;
|
|
236
|
+
const workers = Array.from({ length: concurrency }, async () => {
|
|
237
|
+
while (cursor < list.length) {
|
|
238
|
+
const dep = list[cursor++];
|
|
239
|
+
const entry = await fetchLatestVersion(dep.groupId, dep.artifactId, cache, { offline, repos });
|
|
240
|
+
if (!entry?.latest) continue;
|
|
241
|
+
try {
|
|
242
|
+
if (compareMavenVersions(dep.version, entry.latest) < 0) {
|
|
243
|
+
results.push({ dep, latest: entry.latest, releaseDate: entry.releaseDate || null });
|
|
244
|
+
if (verbose) process.stdout.write(` outdated: ${dep.groupId}:${dep.artifactId} ${dep.version} → ${entry.latest}\n`);
|
|
245
|
+
}
|
|
246
|
+
} catch { /* ignore comparison failure */ }
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
await Promise.all(workers);
|
|
250
|
+
|
|
251
|
+
cache.meta = { fetchedAt: Date.now() };
|
|
252
|
+
saveJsonCache(VERSION_CACHE_PATH, cache);
|
|
253
|
+
results.sort((a, b) => `${a.dep.groupId}:${a.dep.artifactId}`.localeCompare(`${b.dep.groupId}:${b.dep.artifactId}`));
|
|
254
|
+
return results;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
checkEolDeps,
|
|
259
|
+
checkObsoleteDeps,
|
|
260
|
+
checkObsolete,
|
|
261
|
+
checkOutdatedDeps,
|
|
262
|
+
findEolProduct,
|
|
263
|
+
isEolCacheFresh,
|
|
264
|
+
KNOWN_OBSOLETE,
|
|
265
|
+
};
|