fad-checker 2.2.2 → 2.2.3
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/README.md +34 -3
- package/data/popular-packages.json +39 -0
- package/fad-checker-report/cve-report.doc +10987 -0
- package/fad-checker-report/cve-report.html +11126 -0
- package/fad-checker.js +48 -4
- package/lib/core.js +25 -3
- package/lib/cve-match.js +9 -2
- package/lib/json-export.js +5 -0
- package/lib/malware.js +100 -0
- package/lib/osv-db.js +160 -0
- package/lib/version-overlay.js +8 -4
- package/package.json +1 -1
package/fad-checker.js
CHANGED
|
@@ -236,7 +236,10 @@ program
|
|
|
236
236
|
.option("--ignore-test", "skip test-scoped dependencies in report")
|
|
237
237
|
.option("--cve-refresh", "force re-download of CVE database")
|
|
238
238
|
.option("--cve-offline", "use cached CVE index only (no download)")
|
|
239
|
+
.option("--osv-db", "import + match the full local OSV database (Maven) — offline-complete recall, independent of the per-dep OSV cache")
|
|
240
|
+
.option("--osv-db-refresh", "force re-download of the local OSV database")
|
|
239
241
|
.option("--snyk", "run snyk on cleaned POMs and merge into report (requires --target)")
|
|
242
|
+
.option("--typosquat", "flag npm/PyPI deps whose name is one edit from a popular package (heuristic typosquat/slopsquat detection)")
|
|
240
243
|
.option("--no-retire", "skip retire.js vendored-JS scan")
|
|
241
244
|
.option("--no-vendored-js-inventory", "don't list ALL identified vendored JS libs (chapter 1D) — keep only the vulnerable ones (chapter 2)")
|
|
242
245
|
.option("--retire-refresh", "ignore retire cache and re-scan")
|
|
@@ -750,6 +753,9 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
750
753
|
? require("./lib/maven-bom").collectImportBoms(mavenPropsByPom) : [];
|
|
751
754
|
const willBom = importBoms.length > 0;
|
|
752
755
|
const willOsv = !!options.osv;
|
|
756
|
+
// Local OSV DB import (Maven): offline-complete OSV recall, independent of the per-dep
|
|
757
|
+
// OSV.dev cache. Opt-in (downloads ~9 MB once); then matches online or offline.
|
|
758
|
+
const willOsvDb = !!options.osvDb && runMaven;
|
|
753
759
|
const willOutdated = !!options.allLibs;
|
|
754
760
|
const willNvd = !!options.nvd;
|
|
755
761
|
const willEpss = !!options.epss;
|
|
@@ -760,7 +766,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
760
766
|
const willBinaryId = [...resolved.values()].some(d => d.provenance === "binary");
|
|
761
767
|
// License detection piggybacks on the registry passes (same fetched metadata),
|
|
762
768
|
// so it adds no progress step of its own.
|
|
763
|
-
const totalSteps = [willBom, willTransitive, willOverlay, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
|
|
769
|
+
const totalSteps = [willBom, willTransitive, willOverlay, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willOsvDb, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
|
|
764
770
|
const progress = new ui.Progress(totalSteps);
|
|
765
771
|
|
|
766
772
|
if (willBom) {
|
|
@@ -912,6 +918,25 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
912
918
|
}
|
|
913
919
|
}
|
|
914
920
|
|
|
921
|
+
// 4b'. Local OSV database (Maven) — offline-COMPLETE recall. The per-dep OSV.dev
|
|
922
|
+
// queries above only cover deps cached online; the imported full OSV DB matches every
|
|
923
|
+
// dep offline, deterministically, regardless of cache warmth (the OSV-Scanner model).
|
|
924
|
+
if (willOsvDb) {
|
|
925
|
+
const st = progress.start("OSV database (local, Maven)");
|
|
926
|
+
try {
|
|
927
|
+
const { ensureOsvDb, matchOsvDbDeps } = require("./lib/osv-db");
|
|
928
|
+
const index = await ensureOsvDb({ offline, refresh: !!options.osvDbRefresh, verbose, ecosystem: "maven" });
|
|
929
|
+
if (!index) {
|
|
930
|
+
st.done(offline ? "no local OSV DB (run once online with --osv-db)" : "unavailable");
|
|
931
|
+
} else {
|
|
932
|
+
const dbMatches = matchOsvDbDeps(resolved, index);
|
|
933
|
+
const before = cveMatches.length;
|
|
934
|
+
cveMatches = mergeBySource(cveMatches, dbMatches);
|
|
935
|
+
st.done(`${index.count} advisories · ${dbMatches.length} matched · +${cveMatches.length - before} new`);
|
|
936
|
+
}
|
|
937
|
+
} catch (err) { st.fail(err.message); }
|
|
938
|
+
}
|
|
939
|
+
|
|
915
940
|
// 4c. NVD enrichment — canonical description + full CVSS for matched CVEs.
|
|
916
941
|
if (willNvd) {
|
|
917
942
|
const st = progress.start("NVD enrichment");
|
|
@@ -1019,6 +1044,22 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1019
1044
|
}
|
|
1020
1045
|
}
|
|
1021
1046
|
|
|
1047
|
+
// 6b. Supply-chain risk lane (pure + offline): flag KNOWN-MALICIOUS advisories
|
|
1048
|
+
// (OSV MAL-… already in the match set) always, and detect suspected TYPOSQUATS
|
|
1049
|
+
// (opt-in --typosquat, heuristic — names one edit from a popular package).
|
|
1050
|
+
const { flagMalicious, detectTyposquats } = require("./lib/malware");
|
|
1051
|
+
const maliciousCount = flagMalicious(cveMatches);
|
|
1052
|
+
const typosquats = options.typosquat ? detectTyposquats(resolved) : [];
|
|
1053
|
+
if (maliciousCount || typosquats.length) {
|
|
1054
|
+
ui.section("Supply-chain risk");
|
|
1055
|
+
if (maliciousCount) ui.warn(chalk.red.bold(`${maliciousCount} KNOWN-MALICIOUS package advisory(ies) (MAL-…) — treat the build as compromised`));
|
|
1056
|
+
if (typosquats.length) {
|
|
1057
|
+
ui.warn(`${typosquats.length} suspected typosquat(s) — heuristic, verify each is intentional:`);
|
|
1058
|
+
for (const t of typosquats.slice(0, 15)) console.log(" " + chalk.yellow(t.name) + chalk.dim(` (${t.ecosystem}) ≈ ${t.resembles}`));
|
|
1059
|
+
if (typosquats.length > 15) console.log(chalk.dim(` …and ${typosquats.length - 15} more (see JSON export)`));
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1022
1063
|
// Split prod vs dev based on the dep's isDev flag (set at collection time
|
|
1023
1064
|
// from Maven scope=test/provided and npm dev/devOptional/optional). Keep the
|
|
1024
1065
|
// full per-bucket list (including cpeFiltered) so the HTML report can render
|
|
@@ -1253,7 +1294,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1253
1294
|
try {
|
|
1254
1295
|
const { writeFindings } = require("./lib/json-export");
|
|
1255
1296
|
await ensureDir(out.json);
|
|
1256
|
-
writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version }, out.json);
|
|
1297
|
+
writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version, typosquats }, out.json);
|
|
1257
1298
|
wrote.push(["Findings JSON", out.json]);
|
|
1258
1299
|
} catch (err) { ui.warn(`JSON export failed: ${err.message}`); }
|
|
1259
1300
|
}
|
|
@@ -1280,9 +1321,12 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1280
1321
|
const { evaluateGate } = require("./lib/gate");
|
|
1281
1322
|
// Embedded-binary findings are real production risk → gate on them too.
|
|
1282
1323
|
const gate = evaluateGate([...prodActive, ...embeddedActive], options.failOn);
|
|
1283
|
-
|
|
1324
|
+
// A known-malicious package always blocks, regardless of the --fail-on level.
|
|
1325
|
+
const maliciousActive = [...prodActive, ...embeddedActive].filter(m => m.malicious);
|
|
1326
|
+
if (gate.failed || maliciousActive.length) {
|
|
1284
1327
|
ui.section("Gate");
|
|
1285
|
-
console.log(chalk.red(`✗
|
|
1328
|
+
if (maliciousActive.length) console.log(chalk.red.bold(`✗ ${maliciousActive.length} known-malicious package(s) detected — always blocks`));
|
|
1329
|
+
if (gate.failed) console.log(chalk.red(`✗ --fail-on ${options.failOn}: ${gate.reason}`));
|
|
1286
1330
|
process.exitCode = 1;
|
|
1287
1331
|
} else if (verbose) {
|
|
1288
1332
|
ui.info(chalk.dim(`--fail-on ${options.failOn}: no blocking finding`));
|
package/lib/core.js
CHANGED
|
@@ -312,11 +312,33 @@ async function rewritePoms(pomPath, allPomMetadata, allPropsByPom, opts) {
|
|
|
312
312
|
if (!keep.has(k)) delete props[k];
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
// Interpolate ${…} in coordinates against THIS pom's resolved props (incl. the
|
|
316
|
+
// project.* built-ins via getAllInheritedProps' localVars). A reactor sibling dep
|
|
317
|
+
// declared <groupId>${project.groupId}</groupId> must resolve to the real groupId for
|
|
318
|
+
// the byId/local-module lookup + the "missing/private" classification below — otherwise
|
|
319
|
+
// it's wrongly reported as a private lib (e.g. "${project.groupId}:cnaps-core"). The
|
|
320
|
+
// dep NODE itself is left raw, so the rewritten cleaned POM keeps ${…} for Snyk's mvn.
|
|
321
|
+
const pomProps = (allPropsByPom && allPropsByPom[pomPath] && allPropsByPom[pomPath].properties) || {};
|
|
322
|
+
const interp = (s) => {
|
|
323
|
+
if (s == null) return s;
|
|
324
|
+
let out = String(s);
|
|
325
|
+
for (let i = 0; i < 10 && out.includes("${"); i++) {
|
|
326
|
+
const next = out.replace(/\$\{\s*([\w.\-]+)\s*\}/g, (m, k) => {
|
|
327
|
+
const val = pomProps[k];
|
|
328
|
+
if (val == null) return m;
|
|
329
|
+
return Array.isArray(val) ? String(val[0]) : String(val);
|
|
330
|
+
});
|
|
331
|
+
if (next === out) break;
|
|
332
|
+
out = next;
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
};
|
|
336
|
+
|
|
315
337
|
const cleanDeps = list =>
|
|
316
338
|
list?.filter(dep => {
|
|
317
|
-
const g = coord(dep.groupId?.[0]);
|
|
318
|
-
const a = coord(dep.artifactId?.[0]);
|
|
319
|
-
const v = coord(dep.version?.[0]);
|
|
339
|
+
const g = interp(coord(dep.groupId?.[0]));
|
|
340
|
+
const a = interp(coord(dep.artifactId?.[0]));
|
|
341
|
+
const v = interp(coord(dep.version?.[0]));
|
|
320
342
|
if (!g || !a) return false;
|
|
321
343
|
if (deps2Exclude) {
|
|
322
344
|
// Versionless deps inside dependencyManagement-merged result are
|
package/lib/cve-match.js
CHANGED
|
@@ -49,11 +49,18 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
|
|
|
49
49
|
const collectFrom = (depList, pomPath, props) => {
|
|
50
50
|
if (!depList) return;
|
|
51
51
|
for (const dep of depList) {
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
let g = coord(dep.groupId?.[0]);
|
|
53
|
+
let a = coord(dep.artifactId?.[0]);
|
|
54
54
|
let v = coord(dep.version?.[0]);
|
|
55
55
|
const scope = dep.scope?.[0] || "compile";
|
|
56
56
|
if (!g || !a) continue;
|
|
57
|
+
// Interpolate ${…} in the COORDINATE too, not just the version. Reactor sibling
|
|
58
|
+
// deps routinely use ${project.groupId}/${project.artifactId} (Maven built-ins,
|
|
59
|
+
// already in `props` via core.js localVars) — without this they'd enter the scan
|
|
60
|
+
// set as a literal "${project.groupId}:…" coord. Resolve BEFORE the exclude test
|
|
61
|
+
// so `-e` matches the real groupId.
|
|
62
|
+
g = resolveDepVersion(g, props);
|
|
63
|
+
a = resolveDepVersion(a, props);
|
|
57
64
|
if (ignoreTest && scope === "test") continue;
|
|
58
65
|
if (deps2Exclude && deps2Exclude.test(g)) continue;
|
|
59
66
|
if (v) v = resolveDepVersion(v, props);
|
package/lib/json-export.js
CHANGED
|
@@ -49,6 +49,7 @@ function cveFinding(m) {
|
|
|
49
49
|
fixVersion: c.fixVersion || null,
|
|
50
50
|
source: m.source || null,
|
|
51
51
|
confidence: m.confidence || null,
|
|
52
|
+
malicious: !!m.malicious,
|
|
52
53
|
cpeFiltered: !!m.cpeFiltered,
|
|
53
54
|
suppressed: !!m.suppressed,
|
|
54
55
|
suppressedReason: m.suppressedReason || null,
|
|
@@ -67,6 +68,7 @@ function buildFindings(payload = {}) {
|
|
|
67
68
|
const {
|
|
68
69
|
cveMatches = [], retireMatches = [], vendoredJsInventory = [], eolResults = [], obsoleteResults = [],
|
|
69
70
|
outdatedResults = [], licenseResults = null, resolvedDeps, projectInfo = {}, toolVersion = "0",
|
|
71
|
+
typosquats = [],
|
|
70
72
|
} = payload;
|
|
71
73
|
|
|
72
74
|
const { buildInventory } = require("./unmanaged");
|
|
@@ -101,6 +103,8 @@ function buildFindings(payload = {}) {
|
|
|
101
103
|
embedded: embedded.length,
|
|
102
104
|
vendoredJs: vendoredJsInventory.length,
|
|
103
105
|
suppressed: cveMatches.filter(m => m.suppressed).length,
|
|
106
|
+
malicious: cveMatches.filter(m => m.malicious && !m.suppressed).length,
|
|
107
|
+
typosquat: typosquats.length,
|
|
104
108
|
},
|
|
105
109
|
cve: cveMatches.map(cveFinding),
|
|
106
110
|
vendored: retireMatches.map(cveFinding),
|
|
@@ -111,6 +115,7 @@ function buildFindings(payload = {}) {
|
|
|
111
115
|
unmanaged,
|
|
112
116
|
embedded,
|
|
113
117
|
vendoredJs: vendoredJsInventory,
|
|
118
|
+
typosquat: typosquats,
|
|
114
119
|
};
|
|
115
120
|
}
|
|
116
121
|
|
package/lib/malware.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/malware.js — supply-chain risk lane: known-malicious advisories + typosquat
|
|
3
|
+
* heuristic. Pure + fully offline (no extra network; data/popular-packages.json is bundled).
|
|
4
|
+
*
|
|
5
|
+
* Two distinct signals:
|
|
6
|
+
* 1. flagMalicious(matches) — PRECISE. Marks any match whose advisory is a known
|
|
7
|
+
* malicious-package report (OSV `MAL-…` id/alias, or a `malicious` database flag)
|
|
8
|
+
* as malicious. These already flow into the match set via OSV / the local OSV DB;
|
|
9
|
+
* this just recognises and elevates them (gate hard-fails on them, like KEV).
|
|
10
|
+
* 2. detectTyposquats(resolvedDeps) — PROACTIVE / heuristic. Flags an npm or PyPI
|
|
11
|
+
* dependency whose name is edit-distance 1 from a well-known popular package (but
|
|
12
|
+
* isn't it) — the classic typosquat / "slopsquat" pattern (reqeusts, lodahs, …),
|
|
13
|
+
* which no vuln DB catches until after the fact. Conservative (distance 1, length
|
|
14
|
+
* ≥ 5, non-scoped) to bound false positives; opt-in via --typosquat.
|
|
15
|
+
*/
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Damerau-Levenshtein (optimal string alignment) distance, bounded by `max`.
|
|
21
|
+
* Counts an ADJACENT TRANSPOSITION as 1 — the dominant typosquat pattern (lodahs↔lodash,
|
|
22
|
+
* reqeusts↔requests) that plain Levenshtein would score as 2. Returns max+1 when the
|
|
23
|
+
* length gap alone already exceeds the budget. Package names are short, so the full
|
|
24
|
+
* matrix is cheap; the length prefilter handles the obvious skips.
|
|
25
|
+
*/
|
|
26
|
+
function editDistance(a, b, max) {
|
|
27
|
+
if (a === b) return 0;
|
|
28
|
+
const la = a.length, lb = b.length;
|
|
29
|
+
if (Math.abs(la - lb) > max) return max + 1;
|
|
30
|
+
const d = Array.from({ length: la + 1 }, () => new Array(lb + 1).fill(0));
|
|
31
|
+
for (let i = 0; i <= la; i++) d[i][0] = i;
|
|
32
|
+
for (let j = 0; j <= lb; j++) d[0][j] = j;
|
|
33
|
+
for (let i = 1; i <= la; i++) {
|
|
34
|
+
for (let j = 1; j <= lb; j++) {
|
|
35
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
36
|
+
let v = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
37
|
+
if (i > 1 && j > 1 && a.charCodeAt(i - 1) === b.charCodeAt(j - 2) && a.charCodeAt(i - 2) === b.charCodeAt(j - 1)) {
|
|
38
|
+
v = Math.min(v, d[i - 2][j - 2] + 1); // adjacent transposition
|
|
39
|
+
}
|
|
40
|
+
d[i][j] = v;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return d[la][lb] <= max ? d[la][lb] : max + 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let _popular = null;
|
|
47
|
+
function loadPopular(file = path.join(__dirname, "..", "data", "popular-packages.json")) {
|
|
48
|
+
if (_popular) return _popular;
|
|
49
|
+
try {
|
|
50
|
+
const j = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
51
|
+
// PyPI names normalised per PEP 503 (lowercase, fold -/_/.) — same rule the matcher applies.
|
|
52
|
+
_popular = { npm: new Set(j.npm || []), pypi: new Set((j.pypi || []).map(s => s.toLowerCase().replace(/[-_.]+/g, "-"))) };
|
|
53
|
+
} catch { _popular = { npm: new Set(), pypi: new Set() }; }
|
|
54
|
+
return _popular;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Mark matches that are known-malicious advisories. Mutates + returns the count. */
|
|
58
|
+
function flagMalicious(matches) {
|
|
59
|
+
let n = 0;
|
|
60
|
+
for (const m of matches || []) {
|
|
61
|
+
const id = String(m.cve?.id || "");
|
|
62
|
+
const aliases = m.cve?.aliases || [];
|
|
63
|
+
const isMal = id.startsWith("MAL-") || aliases.some(a => String(a).startsWith("MAL-")) || m.cve?.malicious === true;
|
|
64
|
+
if (isMal) { m.malicious = true; if (m.cve) m.cve.malicious = true; n++; }
|
|
65
|
+
}
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Heuristic typosquat detection for npm/PyPI deps. Returns
|
|
71
|
+
* [{ ecosystem, coord, name, resembles, distance }]. PyPI names are normalised
|
|
72
|
+
* (lowercase, - / _ folded) per PEP 503 before comparison.
|
|
73
|
+
*/
|
|
74
|
+
function detectTyposquats(resolvedDeps, opts = {}) {
|
|
75
|
+
const popular = opts.popular || loadPopular();
|
|
76
|
+
const out = [];
|
|
77
|
+
const norm = (eco, name) => eco === "pypi" ? name.toLowerCase().replace(/[-_.]+/g, "-") : name;
|
|
78
|
+
for (const dep of resolvedDeps.values()) {
|
|
79
|
+
const eco = dep.ecosystem;
|
|
80
|
+
if (eco !== "npm" && eco !== "pypi") continue;
|
|
81
|
+
if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
|
|
82
|
+
const rawName = dep.name || dep.artifactId || "";
|
|
83
|
+
if (!rawName || rawName.startsWith("@")) continue; // skip scoped npm (namespace-protected)
|
|
84
|
+
const name = norm(eco, rawName);
|
|
85
|
+
const set = eco === "npm" ? popular.npm : popular.pypi;
|
|
86
|
+
if (!set.size || set.has(eco === "pypi" ? name : rawName)) continue; // a popular pkg itself is not a squat
|
|
87
|
+
if (name.length < 5) continue; // too short → noisy
|
|
88
|
+
let best = null;
|
|
89
|
+
for (const pop of set) {
|
|
90
|
+
const target = eco === "pypi" ? pop : pop; // pypi set already normalised at load
|
|
91
|
+
if (Math.abs(target.length - name.length) > 1) continue;
|
|
92
|
+
const d = editDistance(name, target, 1);
|
|
93
|
+
if (d === 1) { best = target; break; }
|
|
94
|
+
}
|
|
95
|
+
if (best) out.push({ ecosystem: eco, coord: dep.coordKey || rawName, name: rawName, resembles: best, distance: 1 });
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { flagMalicious, detectTyposquats, editDistance, loadPopular };
|
package/lib/osv-db.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/osv-db.js — offline-COMPLETE OSV matching from a locally imported OSV database
|
|
3
|
+
* (the per-ecosystem `all.zip` exports OSV.dev publishes on its public GCS bucket).
|
|
4
|
+
*
|
|
5
|
+
* Why this exists alongside lib/osv.js: the latter queries the OSV.dev API *per
|
|
6
|
+
* dependency* and caches each response (12 h TTL). Offline, that only covers the deps
|
|
7
|
+
* that happened to be queried online before — a cold dep has NO OSV data. This module
|
|
8
|
+
* imports the FULL OSV database once while online (Maven = 9 MB zip, ~6.6 k advisories)
|
|
9
|
+
* and matches EVERY dep against it offline, deterministically, regardless of the per-dep
|
|
10
|
+
* cache. That is exactly the model OSV-Scanner uses for air-gapped scans
|
|
11
|
+
* (`--download-offline-databases`), and it makes fad's offline Maven recall complete and
|
|
12
|
+
* cache-independent for a PASSI engagement.
|
|
13
|
+
*
|
|
14
|
+
* Maven only for now: range matching needs the ecosystem's version ordering, and fad has
|
|
15
|
+
* a robust Maven comparator (lib/maven-version). npm/PyPI/etc. are a documented follow-up
|
|
16
|
+
* (they need semver/PEP-440 ordering). The OSV records are full OSV-schema vuln objects —
|
|
17
|
+
* identical to the API — so lib/osv.js#vulnToMatch is reused verbatim.
|
|
18
|
+
*
|
|
19
|
+
* Online (download+index) + cached (~/.fad-checker/osv-db, 24 h) + offline-aware (loads a
|
|
20
|
+
* present index, never blocks). The index travels in the cache archive automatically.
|
|
21
|
+
*/
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
const path = require("path");
|
|
24
|
+
const os = require("os");
|
|
25
|
+
const { unzipSync } = require("fflate");
|
|
26
|
+
const { compareMavenVersions } = require("./maven-version");
|
|
27
|
+
const { vulnToMatch } = require("./osv");
|
|
28
|
+
|
|
29
|
+
const OSV_DB_DIR = path.join(os.homedir(), ".fad-checker", "osv-db");
|
|
30
|
+
const BUCKET = "https://osv-vulnerabilities.storage.googleapis.com";
|
|
31
|
+
const TTL_MS = 24 * 3600 * 1000;
|
|
32
|
+
// OSV ecosystem export name per fad codec id. Maven only for now (version ordering).
|
|
33
|
+
const ECO = { maven: "Maven" };
|
|
34
|
+
const indexPath = eco => path.join(OSV_DB_DIR, `${eco}-index.json`);
|
|
35
|
+
|
|
36
|
+
/** Keep only what vulnToMatch + matching need, so the cached index stays compact. */
|
|
37
|
+
function trimVuln(vuln, affForPkg) {
|
|
38
|
+
return {
|
|
39
|
+
id: vuln.id,
|
|
40
|
+
aliases: vuln.aliases || [],
|
|
41
|
+
summary: vuln.summary || "",
|
|
42
|
+
details: (vuln.details || "").slice(0, 2000),
|
|
43
|
+
severity: vuln.severity || [],
|
|
44
|
+
...(vuln.database_specific?.severity ? { database_specific: { severity: vuln.database_specific.severity } } : {}),
|
|
45
|
+
references: (vuln.references || []).slice(0, 20),
|
|
46
|
+
published: vuln.published || null,
|
|
47
|
+
modified: vuln.modified || null,
|
|
48
|
+
affected: affForPkg.map(a => ({ package: a.package, ranges: a.ranges || [], versions: a.versions || [] })),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build { byPackage: { "g:a"(lower): [trimmedVuln] }, … } from an all.zip buffer. */
|
|
53
|
+
function buildIndexFromZip(buf, ecosystemName) {
|
|
54
|
+
const files = unzipSync(new Uint8Array(buf));
|
|
55
|
+
const dec = new TextDecoder();
|
|
56
|
+
const byPackage = {};
|
|
57
|
+
let count = 0;
|
|
58
|
+
for (const name of Object.keys(files)) {
|
|
59
|
+
let vuln;
|
|
60
|
+
try { vuln = JSON.parse(dec.decode(files[name])); } catch { continue; }
|
|
61
|
+
if (!vuln || vuln.withdrawn) continue;
|
|
62
|
+
// A vuln can list several packages; index it under each (with that package's affected slice).
|
|
63
|
+
const byPkg = new Map();
|
|
64
|
+
for (const a of (vuln.affected || [])) {
|
|
65
|
+
if (a.package?.ecosystem !== ecosystemName) continue;
|
|
66
|
+
const nm = a.package?.name;
|
|
67
|
+
if (!nm) continue;
|
|
68
|
+
if (!byPkg.has(nm)) byPkg.set(nm, []);
|
|
69
|
+
byPkg.get(nm).push(a);
|
|
70
|
+
}
|
|
71
|
+
for (const [nm, affs] of byPkg) {
|
|
72
|
+
const key = nm.toLowerCase();
|
|
73
|
+
(byPackage[key] = byPackage[key] || []).push(trimVuln(vuln, affs));
|
|
74
|
+
count++;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { _builtAt: Date.now(), ecosystem: ecosystemName, count, packages: Object.keys(byPackage).length, byPackage };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Ensure the local OSV DB index for an ecosystem. Downloads + (re)builds when online and
|
|
82
|
+
* stale/missing; offline (or on any network failure) loads whatever is present, else null.
|
|
83
|
+
*/
|
|
84
|
+
async function ensureOsvDb(opts = {}) {
|
|
85
|
+
const { offline, refresh, verbose, fetcher = globalThis.fetch, ecosystem = "maven" } = opts;
|
|
86
|
+
const ecoName = ECO[ecosystem];
|
|
87
|
+
if (!ecoName) return null;
|
|
88
|
+
const ip = indexPath(ecosystem);
|
|
89
|
+
const loadAny = () => { try { return JSON.parse(fs.readFileSync(ip, "utf8")); } catch { return null; } };
|
|
90
|
+
const loadFresh = () => { const j = loadAny(); return j && (Date.now() - (j._builtAt || 0) < TTL_MS) ? j : null; };
|
|
91
|
+
|
|
92
|
+
if (offline) return loadAny();
|
|
93
|
+
if (!refresh) { const f = loadFresh(); if (f) return f; }
|
|
94
|
+
try {
|
|
95
|
+
const res = await fetcher(`${BUCKET}/${ecoName}/all.zip`, { headers: { "User-Agent": "fad-checker-osv-db" } });
|
|
96
|
+
if (!res || !res.ok) { if (verbose) console.warn(`osv-db: HTTP ${res?.status}`); return loadAny(); }
|
|
97
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
98
|
+
const index = buildIndexFromZip(buf, ecoName);
|
|
99
|
+
fs.mkdirSync(OSV_DB_DIR, { recursive: true });
|
|
100
|
+
fs.writeFileSync(ip, JSON.stringify(index));
|
|
101
|
+
return index;
|
|
102
|
+
} catch (e) { if (verbose) console.warn(`osv-db: ${e.message}`); return loadAny(); }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- OSV range evaluation over Maven version ordering ----
|
|
106
|
+
function inInterval(v, intro, upper, inclusive) {
|
|
107
|
+
if (intro != null && intro !== "0" && compareMavenVersions(v, intro) < 0) return false;
|
|
108
|
+
const c = compareMavenVersions(v, upper);
|
|
109
|
+
return inclusive ? c <= 0 : c < 0;
|
|
110
|
+
}
|
|
111
|
+
function rangeAffects(version, range) {
|
|
112
|
+
if (!range || range.type === "GIT") return false;
|
|
113
|
+
let intro = null;
|
|
114
|
+
for (const e of (range.events || [])) {
|
|
115
|
+
if (e.introduced !== undefined) intro = e.introduced;
|
|
116
|
+
else if (e.fixed !== undefined) { if (inInterval(version, intro, e.fixed, false)) return true; intro = null; }
|
|
117
|
+
else if (e.last_affected !== undefined) { if (inInterval(version, intro, e.last_affected, true)) return true; intro = null; }
|
|
118
|
+
}
|
|
119
|
+
if (intro !== null) { // open interval [introduced, ∞)
|
|
120
|
+
if (intro === "0" || compareMavenVersions(version, intro) >= 0) return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
function vulnAffectsVersion(version, trimmedVuln) {
|
|
125
|
+
for (const a of (trimmedVuln.affected || [])) {
|
|
126
|
+
if ((a.versions || []).includes(version)) return true;
|
|
127
|
+
for (const r of (a.ranges || [])) if (rangeAffects(version, r)) return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Match resolved Maven deps against a local OSV DB index. Returns vulnToMatch-shaped
|
|
134
|
+
* matches (source 'osv'), one per (coord, version, vuln). Scans every distinct version.
|
|
135
|
+
*/
|
|
136
|
+
function matchOsvDbDeps(resolvedDeps, index) {
|
|
137
|
+
if (!index || !index.byPackage) return [];
|
|
138
|
+
const out = [];
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
for (const dep of resolvedDeps.values()) {
|
|
141
|
+
if (dep.ecosystem !== "maven") continue;
|
|
142
|
+
if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
|
|
143
|
+
const recs = index.byPackage[`${dep.groupId}:${dep.artifactId}`.toLowerCase()];
|
|
144
|
+
if (!recs || !recs.length) continue;
|
|
145
|
+
const versions = (dep.versions && dep.versions.length) ? dep.versions : [dep.version];
|
|
146
|
+
for (const ver of versions) {
|
|
147
|
+
if (!ver || /\$\{/.test(String(ver))) continue;
|
|
148
|
+
for (const rec of recs) {
|
|
149
|
+
if (!vulnAffectsVersion(ver, rec)) continue;
|
|
150
|
+
const key = `${dep.groupId}:${dep.artifactId}:${ver}|${rec.id}`;
|
|
151
|
+
if (seen.has(key)) continue;
|
|
152
|
+
seen.add(key);
|
|
153
|
+
out.push(vulnToMatch(ver === dep.version ? dep : { ...dep, version: ver }, rec));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { ensureOsvDb, buildIndexFromZip, matchOsvDbDeps, rangeAffects, vulnAffectsVersion, OSV_DB_DIR };
|
package/lib/version-overlay.js
CHANGED
|
@@ -93,8 +93,9 @@ async function buildModuleManagement(pomPath, store, propsByPom, opts = {}) {
|
|
|
93
93
|
if (!entry) continue;
|
|
94
94
|
const props = entry.properties || {};
|
|
95
95
|
for (const node of entry.dependencyManagement || []) {
|
|
96
|
-
|
|
97
|
-
const
|
|
96
|
+
// Interpolate ${…} in the coordinate (e.g. ${project.groupId}) like the version.
|
|
97
|
+
const g = resolveDepVersion(coord(node.groupId?.[0]), props);
|
|
98
|
+
const a = resolveDepVersion(coord(node.artifactId?.[0]), props);
|
|
98
99
|
if (!g || !a) continue;
|
|
99
100
|
const v = resolveDepVersion(coord(node.version?.[0]), props);
|
|
100
101
|
if (node.scope?.[0] === "import") {
|
|
@@ -134,10 +135,13 @@ function buildModuleDirects(pomPath, propsByPom, moduleMgmt, resolvedDeps, opts
|
|
|
134
135
|
if (!d || !d.groupId || !d.artifactId || d.optional || d.isImport) continue;
|
|
135
136
|
if (d.scope === "test" && !opts.includeTestDeps) continue;
|
|
136
137
|
if (d.scope === "system" || d.scope === "import") continue;
|
|
138
|
+
// Interpolate ${…} in the coordinate (e.g. ${project.groupId}) like the version.
|
|
139
|
+
const gid = resolveDepVersion(d.groupId, props);
|
|
140
|
+
const aid = resolveDepVersion(d.artifactId, props);
|
|
137
141
|
let v = resolveDepVersion(d.rawVersion, props);
|
|
138
|
-
if (!isConcrete(v)) v = moduleMgmt.get(`${
|
|
142
|
+
if (!isConcrete(v)) v = moduleMgmt.get(`${gid}:${aid}`) || resolvedDeps.get(`${gid}:${aid}`)?.version || null;
|
|
139
143
|
if (!isConcrete(v)) continue;
|
|
140
|
-
out.push({ groupId:
|
|
144
|
+
out.push({ groupId: gid, artifactId: aid, version: String(v), scope: d.scope, exclusions: d.exclusions });
|
|
141
145
|
}
|
|
142
146
|
return out;
|
|
143
147
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"description": "Scan ALL Maven, npm, Yarn, Composer, Python, C#/.NET, Go & Ruby dependencies — plus embedded JARs (fat-jars/war/ear) — in a source tree ONE SHOT without mvn/python/etc — CVE (EPSS/KEV-prioritised), EOL, obsolete, outdated & licenses, with SBOM/CSAF/SARIF/JSON exports, CI gating and fix recos",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sca",
|