fad-checker 2.2.1 → 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/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/transitive.js CHANGED
@@ -183,12 +183,18 @@ function resolveProps(value, props, builtins, depth = 0) {
183
183
  async function effectivePom(g, a, v, opts = {}, seen = new Set()) {
184
184
  const key = `${g}:${a}:${v}`;
185
185
  if (seen.has(key)) return null;
186
+ // Opt-in cross-call memo (used by the per-module overlay, which resolves the
187
+ // same shared parents/BOMs once per module). Immutable POMs → the effective
188
+ // result for a g:a:v is stable, so caching the finished object is safe; callers
189
+ // only READ eff.depMgmt/eff.deps. Only active when opts.effCache is supplied,
190
+ // so existing single-shot callers are byte-for-byte unchanged.
191
+ if (opts.effCache && opts.effCache.has(key)) return opts.effCache.get(key);
186
192
  seen.add(key);
187
193
 
188
194
  const xml = await fetchPom(g, a, v, opts);
189
- if (!xml) return null;
195
+ if (!xml) { if (opts.effCache) opts.effCache.set(key, null); return null; }
190
196
  const pom = await parsePomXml(xml);
191
- if (!pom) return null;
197
+ if (!pom) { if (opts.effCache) opts.effCache.set(key, null); return null; }
192
198
 
193
199
  let merged = {
194
200
  groupId: pom.groupId,
@@ -243,6 +249,7 @@ async function effectivePom(g, a, v, opts = {}, seen = new Set()) {
243
249
  }
244
250
  merged.depMgmt = expanded;
245
251
 
252
+ if (opts.effCache) opts.effCache.set(key, merged);
246
253
  return merged;
247
254
  }
248
255
 
@@ -0,0 +1,197 @@
1
+ /**
2
+ * lib/version-overlay.js — recover transitive dependency versions that Maven's
3
+ * PER-MODULE mediation keeps but fad's GLOBAL transitive pass masks.
4
+ *
5
+ * The problem: `expandWithTransitives` (cve-match.js) resolves the whole reactor as
6
+ * ONE tree with ONE global `rootDepMgmt` (the highest version of every coord seen
7
+ * anywhere). So a `<dependencyManagement>` pin in module A is force-applied to a
8
+ * transitive of the unrelated module B — e.g. `controller` pins poi 5.4.1 and the
9
+ * stress-tests island's `jmeter → poi 3.11` gets rewritten to 5.4.1, hiding
10
+ * CVE-2017-12626. Maven applies depMgmt only inside the subtree that declares it.
11
+ *
12
+ * The fix (additive overlay): keep the global pass UNCHANGED as the base (no
13
+ * regression), then re-resolve EACH module independently with ONLY that module's
14
+ * own effective depMgmt (its local parent chain + its external parent/import-BOMs),
15
+ * and APPEND any (g:a, version) it finds that isn't already in the coord's
16
+ * `versions[]`. Never removes, never reseeds — so it can only ADD coverage, and the
17
+ * per-module version it finds is the one genuinely on that module's classpath
18
+ * (so it's a true positive, not a force-elevated one).
19
+ *
20
+ * Offline-aware (cache-first via transitive.js#fetchPom) and memoised across modules
21
+ * with a shared `effCache` so 25 modules stay fast.
22
+ */
23
+ const core = require("./core");
24
+ const { resolveTransitiveDeps, effectivePom } = require("./transitive");
25
+ const { resolveDepVersion } = require("./cve-match");
26
+
27
+ const coord = core.coord;
28
+ const isConcrete = v => v != null && !/\$\{/.test(String(v));
29
+
30
+ /** xml2js dependency node → flat descriptor (groupId/artifactId/version/scope/…). */
31
+ function flattenDep(d) {
32
+ return {
33
+ groupId: coord(d.groupId?.[0]),
34
+ artifactId: coord(d.artifactId?.[0]),
35
+ rawVersion: coord(d.version?.[0]),
36
+ scope: coord(d.scope?.[0]) || "compile",
37
+ optional: d.optional?.[0] === "true",
38
+ isImport: d.scope?.[0] === "import",
39
+ exclusions: (d.exclusions?.[0]?.exclusion || []).map(e => ({
40
+ groupId: coord(e.groupId?.[0]),
41
+ artifactId: coord(e.artifactId?.[0]),
42
+ })),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Walk a module's LOCAL parent chain (child first). Stops at the first external
48
+ * parent (one not present in the source tree) and reports it separately, so the
49
+ * caller can resolve its managed table from Maven Central.
50
+ * @returns { chain: [pomPath, parentPath, …], externalParent: {groupId,artifactId,version}|null }
51
+ */
52
+ function localChain(pomPath, store) {
53
+ const chain = [];
54
+ const seen = new Set();
55
+ let cur = pomPath;
56
+ let externalParent = null;
57
+ while (cur && !seen.has(cur)) {
58
+ seen.add(cur);
59
+ chain.push(cur);
60
+ const meta = store.byPath[cur];
61
+ if (!meta) break;
62
+ const parentPath = core.resolveParentPath(cur, meta.parentInfo, store);
63
+ if (!parentPath) {
64
+ const p = meta.parentInfo;
65
+ if (p?.groupId && p?.artifactId && p?.version) externalParent = { groupId: p.groupId, artifactId: p.artifactId, version: p.version };
66
+ break;
67
+ }
68
+ cur = parentPath;
69
+ }
70
+ return { chain, externalParent };
71
+ }
72
+
73
+ /**
74
+ * Build the EFFECTIVE managed-version map for ONE module — exactly the depMgmt that
75
+ * Maven would apply when resolving THIS module's dependencies, and no other module's.
76
+ * Closest-declared pin wins (set-if-absent while climbing child → parent).
77
+ *
78
+ * Sources, in precedence order:
79
+ * 1. each local pom in the parent chain's own <dependencyManagement> (local
80
+ * scope=import BOMs are already expanded inline by core.getAllInheritedProps);
81
+ * 2. the chain's external <parent> (e.g. spring-boot-starter-parent) managed table;
82
+ * 3. external scope=import BOMs (e.g. spring-boot-dependencies) declared in the chain.
83
+ * @returns Map<"g:a", version>
84
+ */
85
+ async function buildModuleManagement(pomPath, store, propsByPom, opts = {}) {
86
+ const map = new Map();
87
+ const setIf = (k, v) => { if (k && isConcrete(v) && !map.has(k)) map.set(k, String(v)); };
88
+ const { chain, externalParent } = localChain(pomPath, store);
89
+ const externalBoms = [];
90
+
91
+ for (const pom of chain) {
92
+ const entry = propsByPom[pom];
93
+ if (!entry) continue;
94
+ const props = entry.properties || {};
95
+ for (const node of entry.dependencyManagement || []) {
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);
99
+ if (!g || !a) continue;
100
+ const v = resolveDepVersion(coord(node.version?.[0]), props);
101
+ if (node.scope?.[0] === "import") {
102
+ // Local import BOMs are already expanded inline by getAllInheritedProps;
103
+ // only chase EXTERNAL ones (not present in the source tree).
104
+ const local = (v && store.byId[`${g}:${a}:${v}`]) || store.byId[`${g}:${a}`];
105
+ if (!local && isConcrete(v)) externalBoms.push({ groupId: g, artifactId: a, version: v });
106
+ continue;
107
+ }
108
+ setIf(`${g}:${a}`, v);
109
+ }
110
+ }
111
+
112
+ // External parent + external import BOMs: pull their managed tables from Maven
113
+ // Central (cache-first, memoised). Closest local pins already set win.
114
+ const externals = externalParent ? [externalParent, ...externalBoms] : externalBoms;
115
+ for (const ext of externals) {
116
+ let eff = null;
117
+ try { eff = await effectivePom(ext.groupId, ext.artifactId, ext.version, opts); } catch { eff = null; }
118
+ if (eff?.depMgmt) for (const d of eff.depMgmt) setIf(`${d.groupId}:${d.artifactId}`, d.version);
119
+ }
120
+ return map;
121
+ }
122
+
123
+ /**
124
+ * The direct dependencies declared by ONE module, with concrete versions resolved
125
+ * (own ${properties} → module's managed map → the global resolved version). These
126
+ * seed the per-module transitive walk.
127
+ */
128
+ function buildModuleDirects(pomPath, propsByPom, moduleMgmt, resolvedDeps, opts = {}) {
129
+ const entry = propsByPom[pomPath];
130
+ if (!entry) return [];
131
+ const props = entry.properties || {};
132
+ const out = [];
133
+ for (const node of entry.dependencies || []) {
134
+ const d = flattenSafe(node);
135
+ if (!d || !d.groupId || !d.artifactId || d.optional || d.isImport) continue;
136
+ if (d.scope === "test" && !opts.includeTestDeps) continue;
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);
141
+ let v = resolveDepVersion(d.rawVersion, props);
142
+ if (!isConcrete(v)) v = moduleMgmt.get(`${gid}:${aid}`) || resolvedDeps.get(`${gid}:${aid}`)?.version || null;
143
+ if (!isConcrete(v)) continue;
144
+ out.push({ groupId: gid, artifactId: aid, version: String(v), scope: d.scope, exclusions: d.exclusions });
145
+ }
146
+ return out;
147
+ }
148
+ // flattenSafe guards against malformed nodes (xml2js can yield odd shapes).
149
+ function flattenSafe(node) { try { return flattenDep(node); } catch { return null; } }
150
+
151
+ /**
152
+ * Additive per-module overlay. Mutates `resolvedDeps`: for every coord already in
153
+ * the scan set, appends any concrete version found by a faithful per-module
154
+ * resolution that the global pass masked. Returns a small diagnostics object incl.
155
+ * the (g:a, version) pairs recovered (used for the false-positive measurement).
156
+ */
157
+ async function expandPerModuleOverlay(resolvedDeps, store, propsByPom, opts = {}) {
158
+ if (!store || !propsByPom) return { appended: 0, modules: 0, recovered: [] };
159
+ const effCache = opts.effCache || new Map();
160
+ const tOpts = { ...opts, effCache };
161
+ const recovered = [];
162
+ let modules = 0;
163
+
164
+ for (const pomPath of Object.keys(propsByPom)) {
165
+ const moduleMgmt = await buildModuleManagement(pomPath, store, propsByPom, tOpts);
166
+ const directs = buildModuleDirects(pomPath, propsByPom, moduleMgmt, resolvedDeps, tOpts);
167
+ if (!directs.length) continue;
168
+ modules++;
169
+
170
+ let trans;
171
+ try {
172
+ trans = await resolveTransitiveDeps(directs, {
173
+ ...tOpts,
174
+ rootDepMgmt: moduleMgmt,
175
+ maxDepth: opts.maxDepth || 6,
176
+ includedScopes: ["compile", "runtime", "provided"],
177
+ });
178
+ } catch { continue; }
179
+
180
+ for (const [key, t] of trans) {
181
+ const v = t.version;
182
+ if (!isConcrete(v)) continue;
183
+ const existing = resolvedDeps.get(key);
184
+ if (!existing) continue; // additive to coords already scanned
185
+ if (existing.provenance === "embedded" || existing.provenance === "binary") continue;
186
+ if (!Array.isArray(existing.versions)) existing.versions = isConcrete(existing.version) ? [existing.version] : [];
187
+ if (existing.versions.includes(String(v))) continue; // already scanned this version
188
+ existing.versions.push(String(v));
189
+ existing.maskedVersions = existing.maskedVersions || [];
190
+ existing.maskedVersions.push({ version: String(v), via: t.via, viaPaths: t.viaPaths, module: pomPath, depth: t.depth });
191
+ recovered.push({ coord: key, version: String(v), module: pomPath, had: existing.version });
192
+ }
193
+ }
194
+ return { appended: recovered.length, modules, recovered };
195
+ }
196
+
197
+ module.exports = { expandPerModuleOverlay, buildModuleManagement, buildModuleDirects, localChain };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "2.2.1",
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",