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.
- package/CHANGELOG.md +65 -0
- package/README.md +147 -25
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +14 -1
- package/data/eol-mapping.json +5 -5
- package/data/license-policy.json +97 -0
- package/fad-checker-report/cve-report.doc +630 -0
- package/fad-checker-report/cve-report.html +748 -0
- package/fad-checker.js +556 -245
- package/lib/cache-archive.js +3 -0
- package/lib/codecs/codec.interface.js +3 -0
- package/lib/codecs/composer/parse.js +3 -0
- package/lib/codecs/composer/registry.js +13 -5
- package/lib/codecs/composer.codec.js +3 -0
- package/lib/codecs/go/parse.js +65 -0
- package/lib/codecs/go/registry.js +74 -0
- package/lib/codecs/go.codec.js +76 -0
- package/lib/codecs/index.js +7 -2
- package/lib/codecs/maven/jar-scan.js +199 -0
- package/lib/codecs/maven.codec.js +17 -1
- package/lib/codecs/npm/collect.js +37 -17
- package/lib/codecs/npm/parse.js +117 -10
- package/lib/codecs/npm/registry.js +14 -5
- package/lib/codecs/npm.codec.js +4 -1
- package/lib/codecs/nuget/parse.js +3 -0
- package/lib/codecs/nuget/registry.js +11 -4
- package/lib/codecs/nuget.codec.js +7 -2
- package/lib/codecs/pypi/parse.js +111 -14
- package/lib/codecs/pypi/registry.js +24 -5
- package/lib/codecs/pypi.codec.js +30 -6
- package/lib/codecs/recipes.js +28 -1
- package/lib/codecs/ruby/parse.js +42 -0
- package/lib/codecs/ruby/registry.js +76 -0
- package/lib/codecs/ruby.codec.js +66 -0
- package/lib/codecs/select.js +3 -0
- package/lib/codecs/yarn.codec.js +3 -0
- package/lib/config.js +3 -0
- package/lib/core.js +38 -1
- package/lib/cpe.js +30 -5
- package/lib/csaf-export.js +159 -0
- package/lib/cve-download.js +3 -0
- package/lib/cve-match.js +27 -6
- package/lib/cve-report.js +157 -28
- package/lib/dep-record.js +15 -2
- package/lib/deps-descriptor.js +113 -0
- package/lib/epss.js +115 -0
- package/lib/gate.js +45 -0
- package/lib/json-export.js +110 -0
- package/lib/kev.js +88 -0
- package/lib/license-policy.js +110 -0
- package/lib/maven-license.js +52 -0
- package/lib/maven-repo.js +3 -0
- package/lib/maven-version.js +8 -3
- package/lib/nvd.js +17 -6
- package/lib/osv.js +75 -23
- package/lib/outdated.js +10 -2
- package/lib/priority.js +90 -0
- package/lib/purl.js +77 -0
- package/lib/retire.js +80 -13
- package/lib/sarif-export.js +134 -0
- package/lib/sbom-export.js +153 -0
- package/lib/scan-completeness.js +3 -0
- package/lib/snyk.js +3 -0
- package/lib/suppress.js +113 -0
- package/lib/transitive.js +6 -3
- package/lib/ui.js +90 -0
- package/package.json +49 -2
- package/CLAUDE.md +0 -147
- package/docs/ARCHITECTURE.md +0 -182
- package/docs/USAGE.md +0 -187
- package/docs/superpowers/plans/2026-05-29-codec-composer.md +0 -556
- package/docs/superpowers/plans/2026-05-29-codec-foundation.md +0 -851
- package/docs/superpowers/plans/2026-05-29-codec-nuget.md +0 -432
- package/docs/superpowers/plans/2026-05-29-codec-pypi.md +0 -450
- package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +0 -251
- package/test/cli-ecosystem.test.js +0 -30
- package/test/codec-edge-cases.test.js +0 -131
- package/test/codec-integration.test.js +0 -119
- package/test/codecs.test.js +0 -92
- package/test/composer.test.js +0 -71
- package/test/core.test.js +0 -153
- package/test/cpe.test.js +0 -166
- package/test/cve-download.test.js +0 -39
- package/test/cve-match.test.js +0 -217
- package/test/cve-report.test.js +0 -180
- package/test/dep-record.test.js +0 -31
- package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
- package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
- package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
- package/test/fixtures/complex-enterprise/pom.xml +0 -66
- package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
- package/test/fixtures/csharp-config/packages.config +0 -4
- package/test/fixtures/csharp-csproj/Directory.Packages.props +0 -5
- package/test/fixtures/csharp-csproj/app.csproj +0 -7
- package/test/fixtures/csharp-lock/packages.lock.json +0 -6
- package/test/fixtures/cve-samples/cve-non-java.json +0 -19
- package/test/fixtures/cve-samples/cve-product-only.json +0 -31
- package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
- package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
- package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
- package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
- package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
- package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -10
- package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
- package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
- package/test/fixtures/monorepo-mixed/pom.xml +0 -29
- package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
- package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
- package/test/fixtures/php-app/composer.json +0 -5
- package/test/fixtures/php-app/composer.lock +0 -10
- package/test/fixtures/polyglot/cs/packages.lock.json +0 -1
- package/test/fixtures/polyglot/js/package-lock.json +0 -1
- package/test/fixtures/polyglot/js/package.json +0 -1
- package/test/fixtures/polyglot/mvn/pom.xml +0 -7
- package/test/fixtures/polyglot/php/composer.lock +0 -1
- package/test/fixtures/polyglot/py/poetry.lock +0 -3
- package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
- package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
- package/test/fixtures/private-lib-detection/pom.xml +0 -35
- package/test/fixtures/python-pipenv/Pipfile.lock +0 -1
- package/test/fixtures/python-poetry/poetry.lock +0 -7
- package/test/fixtures/python-reqs/requirements.txt +0 -5
- package/test/fixtures/python-uv/uv.lock +0 -3
- package/test/fixtures/simple/app/pom.xml +0 -28
- package/test/fixtures/simple/lib/pom.xml +0 -18
- package/test/fixtures/simple/pom.xml +0 -24
- package/test/maven-repo.test.js +0 -111
- package/test/maven-version.test.js +0 -57
- package/test/monorepo.test.js +0 -132
- package/test/npm-registry.test.js +0 -64
- package/test/npm.test.js +0 -150
- package/test/nuget.test.js +0 -66
- package/test/osv.test.js +0 -62
- package/test/outdated.test.js +0 -101
- package/test/pypi.test.js +0 -72
- package/test/snyk.test.js +0 -64
- package/test/transitive.test.js +0 -305
- 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
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
73
|
-
|
|
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 (
|
|
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 (
|
|
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
|
};
|
package/lib/priority.js
ADDED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
};
|