fad-checker 2.4.0 → 2.4.2

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.
@@ -70,9 +70,29 @@ async function resolveRegistrationBase(base, fetcher, headers) {
70
70
  return resolved;
71
71
  }
72
72
 
73
+ // Packages with many versions ship a PAGED registration index: pages carry an
74
+ // "@id" URL + [lower, upper] range instead of inline items. Without fetching
75
+ // them, deprecation/outdated came back silently empty for exactly the popular
76
+ // packages (Newtonsoft.Json, Serilog, …). Fetch only what the findings need:
77
+ // the LAST page (latest version) and the page whose range covers `version`.
78
+ async function inlineNeededPages(reg, version, fetcher, headers) {
79
+ const pages = reg.items || [];
80
+ const need = new Set();
81
+ const last = pages[pages.length - 1];
82
+ if (last && !Array.isArray(last.items) && last["@id"]) need.add(last);
83
+ for (const p of pages) {
84
+ if (Array.isArray(p.items) || !p["@id"]) continue;
85
+ if (version && p.lower != null && p.upper != null && cmp(version, p.lower) >= 0 && cmp(version, p.upper) <= 0) need.add(p);
86
+ }
87
+ await Promise.all([...need].map(async p => {
88
+ try { const r = await fetcher(p["@id"], { headers }); if (r.ok) { const j = await r.json(); if (Array.isArray(j.items)) p.items = j.items; } }
89
+ catch { /* page stays un-inlined — findings degrade, don't fail */ }
90
+ }));
91
+ }
92
+
73
93
  // Custom registries first (NuGet v3 registration API: <regBase>/<lowerid>/index.json),
74
94
  // then api.nuget.org. A service-index base is resolved to its RegistrationsBaseUrl.
75
- async function fetchRegistration(name, { offline, registries = [], fetcher = globalThis.fetch } = {}) {
95
+ async function fetchRegistration(name, { offline, version = null, registries = [], fetcher = globalThis.fetch } = {}) {
76
96
  if (offline) return null;
77
97
  const id = name.toLowerCase();
78
98
  const bases = withPublic("nuget", registries);
@@ -86,7 +106,11 @@ async function fetchRegistration(name, { offline, registries = [], fetcher = glo
86
106
  if (!regBase) { lastErr = "no registration base"; continue; }
87
107
  try {
88
108
  const res = await fetcher(`${regBase}${id}/index.json`, { headers });
89
- if (res.ok) return await res.json();
109
+ if (res.ok) {
110
+ const reg = await res.json();
111
+ await inlineNeededPages(reg, version, fetcher, headers);
112
+ return reg;
113
+ }
90
114
  lastErr = `HTTP ${res.status}`;
91
115
  } catch (e) { lastErr = e.message; }
92
116
  }
@@ -107,7 +131,7 @@ async function checkNugetRegistryDeps(deps, opts = {}) {
107
131
  const key = `${t.name.toLowerCase()}@${t.version}`;
108
132
  let ex = cache.entries[key];
109
133
  if (!ex) {
110
- const reg = await fetchRegistration(t.name, { offline, registries, ...(fetcher ? { fetcher } : {}) });
134
+ const reg = await fetchRegistration(t.name, { offline, version: t.version, registries, ...(fetcher ? { fetcher } : {}) });
111
135
  if (reg && !reg.error) {
112
136
  const f = nugetRegistrationToFindings(reg, { version: t.version });
113
137
  ex = { deprecated: f.deprecated, latest: f.outdated?.latest || null, license: f.license || null };
@@ -126,7 +150,9 @@ async function checkNugetRegistryDeps(deps, opts = {}) {
126
150
  if (verbose) process.stdout.write(` outdated: ${t.name} ${t.version} → ${ex.latest}\n`);
127
151
  }
128
152
  })));
129
- cache.meta = { fetchedAt: Date.now() };
153
+ // Offline reads must not re-stamp freshness — a stale cache would then look
154
+ // fresh to the next ONLINE run and skip its refetch.
155
+ if (!offline) cache.meta = { fetchedAt: Date.now() };
130
156
  saveCache(cache);
131
157
  return result;
132
158
  }
@@ -54,7 +54,40 @@ module.exports = {
54
54
  const parsedManifests = [];
55
55
  const add = (d, manifestPath) => {
56
56
  if (deps2Exclude && deps2Exclude.test(d.name)) return;
57
- out.set(coordKeyFor("nuget", "", d.name), makeDepRecord({ ecosystem: "nuget", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev }));
57
+ const key = coordKeyFor("nuget", "", d.name);
58
+ const rec = makeDepRecord({ ecosystem: "nuget", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev });
59
+ const existing = out.get(key);
60
+ if (!existing) { out.set(key, rec); return; }
61
+ // Same id across projects/TFMs: merge manifests and scan EVERY distinct
62
+ // resolved version (same convention as Maven) — set() overwrote the first.
63
+ for (const p of rec.manifestPaths) if (!existing.manifestPaths.includes(p)) existing.manifestPaths.push(p);
64
+ if (!existing.version && rec.version) { existing.version = rec.version; if (!existing.versions.includes(rec.version)) existing.versions.push(rec.version); }
65
+ else if (rec.version && !existing.versions.includes(rec.version)) existing.versions.push(rec.version);
66
+ if (existing.isDev && !rec.isDev) { existing.isDev = false; existing.scope = rec.scope || "prod"; }
67
+ };
68
+ // Directory.Packages.props applies to every project BELOW it (MSBuild walks
69
+ // UP from the project dir; nearest file wins). Looking only in the project's
70
+ // own directory missed root-level CPM — i.e. every modern centrally-managed
71
+ // solution collected ZERO deps. Memoised per starting dir.
72
+ const cpmMemo = new Map();
73
+ const cpmFor = async (startDir) => {
74
+ if (cpmMemo.has(startDir)) return cpmMemo.get(startDir);
75
+ let found = { map: {}, path: null };
76
+ let cur = startDir;
77
+ while (true) {
78
+ const fp = path.join(cur, "Directory.Packages.props");
79
+ if (fs.existsSync(fp)) {
80
+ try { found = { map: await N.parseDirectoryPackagesProps(fp), path: fp }; }
81
+ catch { /* unparsable props → best-effort without CPM */ }
82
+ break;
83
+ }
84
+ if (path.resolve(cur) === path.resolve(dir)) break;
85
+ const parent = path.dirname(cur);
86
+ if (parent === cur) break;
87
+ cur = parent;
88
+ }
89
+ cpmMemo.set(startDir, found);
90
+ return found;
58
91
  };
59
92
  for (const g of findNugetDirs(dir, dirFilter(dir, opts))) {
60
93
  if (g.files.includes("packages.lock.json")) {
@@ -65,12 +98,9 @@ module.exports = {
65
98
  continue; // lockfile is authoritative for this directory
66
99
  }
67
100
  // No lockfile → best-effort from .csproj (+CPM) and packages.config + warning.
68
- let cpm = {};
69
- if (g.files.includes("Directory.Packages.props")) {
70
- const cpmFp = path.join(g.dir, "Directory.Packages.props");
71
- parsedManifests.push(cpmFp);
72
- try { cpm = await N.parseDirectoryPackagesProps(cpmFp); } catch { /* ignore */ }
73
- }
101
+ const cpmRes = g.csprojs.length ? await cpmFor(g.dir) : { map: {}, path: null };
102
+ const cpm = cpmRes.map;
103
+ if (cpmRes.path && !parsedManifests.includes(cpmRes.path)) parsedManifests.push(cpmRes.path);
74
104
  let pinned = 0, skipped = 0;
75
105
  for (const cs of g.csprojs) {
76
106
  const fp = path.join(g.dir, cs);
@@ -40,6 +40,9 @@ function parseTomlPackages(filePath, type) {
40
40
  const deps = [];
41
41
  for (const p of pkgs) {
42
42
  if (!p.name || !p.version) continue;
43
+ // uv.lock lists the project ITSELF as a package (source.virtual/editable = ".");
44
+ // it is not a registry dep and its name could shadow a real PyPI package.
45
+ if (p.source && (p.source.virtual != null || p.source.editable != null)) continue;
43
46
  // pdm/poetry≥1.5 use `groups`; classic poetry.lock (≤1.4) uses `category = "dev"`.
44
47
  const groups = Array.isArray(p.groups) ? p.groups : null;
45
48
  const isDev = groups
@@ -130,14 +133,20 @@ function collectReqGraph(filePath, seen, kind, acc) {
130
133
  catch { acc.missing.push(real); return; }
131
134
  const dir = path.dirname(real);
132
135
  for (const raw of lines) {
133
- const line = raw.replace(/(^|\s)#.*$/, "").trim();
136
+ let line = raw.replace(/(^|\s)#.*$/, "").trim();
137
+ if (!line) continue;
138
+ // pip-compile --generate-hashes writes "pkg==1.0 \" + indented "--hash=…"
139
+ // continuation lines. The trailing backslash made isPinned() reject the
140
+ // spec, silently skipping EVERY dep of a hash-pinned file.
141
+ line = line.replace(/\\$/, "").trim();
134
142
  if (!line) continue;
135
143
  const rMatch = line.match(/^(?:-r|--requirement)[=\s]+(.+)$/);
136
144
  if (rMatch) { collectReqGraph(path.resolve(dir, rMatch[1].trim()), seen, kind, acc); continue; }
137
145
  const cMatch = line.match(/^(?:-c|--constraint)[=\s]+(.+)$/);
138
146
  if (cMatch) { collectReqGraph(path.resolve(dir, cMatch[1].trim()), seen, "constraint", acc); continue; }
139
147
  if (line.startsWith("-")) continue; // -e ., --hash, other flags
140
- const { name, spec } = splitPep508(line);
148
+ // Per-requirement options ("pkg==1.0 --hash=sha256:…") come after the spec.
149
+ const { name, spec } = splitPep508(line.split(/\s+--/)[0].trim());
141
150
  if (!name) continue;
142
151
  const n = pep503(name);
143
152
  if (kind === "constraint") acc.constraints.set(n, spec);
@@ -106,7 +106,9 @@ async function checkPypiRegistryDeps(deps, opts = {}) {
106
106
  if (verbose) process.stdout.write(` outdated: ${t.name} ${t.version} → ${ex.latest}\n`);
107
107
  }
108
108
  })));
109
- cache.meta = { fetchedAt: Date.now() };
109
+ // Offline reads must not re-stamp freshness — a stale cache would then look
110
+ // fresh to the next ONLINE run and skip its refetch.
111
+ if (!offline) cache.meta = { fetchedAt: Date.now() };
110
112
  saveCache(cache);
111
113
  return result;
112
114
  }
@@ -72,6 +72,9 @@ module.exports = {
72
72
  // version, and let prod win over dev (declared prod anywhere ⇒ prod).
73
73
  for (const p of rec.manifestPaths) if (!existing.manifestPaths.includes(p)) existing.manifestPaths.push(p);
74
74
  if (!existing.version && rec.version) { existing.version = rec.version; if (!existing.versions.includes(rec.version)) existing.versions.push(rec.version); }
75
+ // Two files pinning DIFFERENT versions of the same package: every distinct
76
+ // concrete version is scanned (same convention as Maven), not just the first.
77
+ else if (rec.version && !existing.versions.includes(rec.version)) existing.versions.push(rec.version);
75
78
  if (existing.isDev && !rec.isDev) { existing.isDev = false; existing.scope = rec.scope || "prod"; }
76
79
  };
77
80
  for (const g of findPyDirs(dir, pyDirFilter(dir, opts))) {
@@ -16,6 +16,8 @@ const { execFile } = require("child_process");
16
16
  const { promisify } = require("util");
17
17
  const execFileP = promisify(execFile);
18
18
 
19
+ const { compareMavenVersions, versionLikeBound } = require("./maven-version");
20
+
19
21
  const CVE_DATA_DIR = path.join(os.homedir(), ".fad-checker", "cve-data");
20
22
  const CVE_INDEX_PATH = path.join(CVE_DATA_DIR, "maven-cve-index.json");
21
23
  const CVE_META_PATH = path.join(CVE_DATA_DIR, "meta.json");
@@ -42,6 +44,45 @@ const KNOWN_JAVA_VENDORS = new Set([
42
44
  "squareup", "okhttp",
43
45
  ]);
44
46
 
47
+ // Curated CPE vendor:product → Maven coord map, shared with lib/cpe.js. Used
48
+ // here at index-build time to (1) keep product-only records that name a known
49
+ // Java product and (2) backfill their packageName so tier-1 exact matching works.
50
+ let COORD_MAP_CACHE = null;
51
+ function coordMap() {
52
+ if (!COORD_MAP_CACHE) {
53
+ try { COORD_MAP_CACHE = require("../data/cpe-coord-map.json"); }
54
+ catch { COORD_MAP_CACHE = { byVendorProduct: {} }; }
55
+ }
56
+ return COORD_MAP_CACHE;
57
+ }
58
+
59
+ /**
60
+ * Resolve a CNA vendor/product pair to curated Maven coords. CNAs publish
61
+ * legal-entity vendors ("Apache Software Foundation") and display products
62
+ * ("Apache Log4j2"), so the lookup tries every vendor token against the
63
+ * product as written (spaces → "_", CPE convention) and with the leading
64
+ * vendor word stripped ("apache log4j2" → "log4j2").
65
+ */
66
+ function curatedCoordsFor(vendor, product) {
67
+ if (!product) return [];
68
+ const map = coordMap().byVendorProduct || {};
69
+ const p = String(product).toLowerCase().trim();
70
+ const vTokens = String(vendor || "").toLowerCase().split(/[^a-z0-9.]+/).filter(Boolean);
71
+ const candidates = new Set([p.replace(/\s+/g, "_")]);
72
+ const words = p.split(/\s+/);
73
+ while (words.length > 1 && vTokens.includes(words[0])) words.shift();
74
+ candidates.add(words.join("_"));
75
+ const out = new Set();
76
+ for (const vt of vTokens) {
77
+ for (const cand of candidates) {
78
+ for (const c of map[`${vt}:${cand}`] || []) out.add(c);
79
+ }
80
+ }
81
+ return [...out];
82
+ }
83
+
84
+ const PRODUCT_HEURISTIC = /^(spring-|jackson-|commons-|hibernate-|netty-|guava|log4j|slf4j|okhttp|httpclient|jetty-|tomcat-|struts-)/;
85
+
45
86
  function isMavenRelevant(affected) {
46
87
  if (!affected || !Array.isArray(affected)) return false;
47
88
  for (const a of affected) {
@@ -50,9 +91,16 @@ function isMavenRelevant(affected) {
50
91
  if (a.versions?.some(v => v.versionType === "maven")) return true;
51
92
  const v = String(a.vendor || "").toLowerCase();
52
93
  const p = String(a.product || "").toLowerCase();
94
+ // CNAs publish legal-entity vendor strings ("Apache Software Foundation",
95
+ // "QOS.ch Sarl"): any single token matching a known Java vendor counts —
96
+ // exact-set membership alone dropped CVE-2021-44228 from the index.
53
97
  if (KNOWN_JAVA_VENDORS.has(v)) return true;
54
- // Heuristic: product names that look like Maven artifactIds
55
- if (/^(spring-|jackson-|commons-|hibernate-|netty-|guava|log4j|slf4j|okhttp|httpclient|jetty-|tomcat-|struts-)/.test(p)) return true;
98
+ if (v.split(/[^a-z0-9.]+/).some(t => KNOWN_JAVA_VENDORS.has(t))) return true;
99
+ // Heuristic: product names that look like Maven artifactIds — also after
100
+ // stripping a leading vendor word ("apache log4j2" → "log4j2").
101
+ const stripped = p.replace(/^[a-z0-9.]+\s+/, "");
102
+ if (PRODUCT_HEURISTIC.test(p) || PRODUCT_HEURISTIC.test(stripped)) return true;
103
+ if (curatedCoordsFor(a.vendor, a.product).length) return true;
56
104
  }
57
105
  return false;
58
106
  }
@@ -91,31 +139,70 @@ function severityFromScore(s) {
91
139
  return "NONE";
92
140
  }
93
141
 
142
+ /**
143
+ * CVE 5.x `versions[]` entries may carry a `changes[]` timeline: the range
144
+ * [version, lessThan*) starts in `status` and flips at each `at` boundary.
145
+ * That is how CVE-2021-44228 encodes its affected windows (2.0-beta9→2.3.1,
146
+ * 2.4→2.12.2, 2.13.0→2.15.0). Expand them into plain affected windows the
147
+ * matcher already understands; ignoring `changes` would both miss real hits
148
+ * and flag the patched 2.12.2/2.3.1 backport lines.
149
+ */
150
+ function expandVersionChanges(v) {
151
+ const changes = Array.isArray(v.changes) ? v.changes.filter(c => c && c.at) : [];
152
+ if (!changes.length) return null;
153
+ const sorted = [...changes].sort((a, b) => compareMavenVersions(a.at, b.at));
154
+ const windows = [];
155
+ let status = String(v.status || "affected").toLowerCase();
156
+ let start = v.version;
157
+ for (const c of sorted) {
158
+ if (status === "affected") {
159
+ windows.push({ version: start, status: "affected", versionType: v.versionType, lessThan: c.at, lessThanOrEqual: undefined });
160
+ }
161
+ status = String(c.status || "").toLowerCase();
162
+ start = c.at;
163
+ }
164
+ if (status === "affected") {
165
+ windows.push({ version: start, status: "affected", versionType: v.versionType, lessThan: v.lessThan, lessThanOrEqual: v.lessThanOrEqual });
166
+ }
167
+ return windows;
168
+ }
169
+
94
170
  function extractAffectedRanges(affected) {
95
171
  const out = [];
96
172
  for (const a of affected || []) {
97
- const packageName = a.packageName && String(a.packageName).includes(":") ? a.packageName : null;
173
+ const explicit = a.packageName && String(a.packageName).includes(":") ? a.packageName : null;
174
+ // Product-only records (the common shape for pre-2023 CVEs) get their
175
+ // coordinate(s) from the curated map so they land in byPackageName and
176
+ // match at tier-1. One product may map to several artifacts.
177
+ const packageNames = explicit ? [explicit] : curatedCoordsFor(a.vendor, a.product);
98
178
  const vendor = a.vendor ? String(a.vendor).toLowerCase() : null;
99
179
  const product = a.product ? String(a.product).toLowerCase() : null;
100
180
  const versions = Array.isArray(a.versions) ? a.versions : [];
101
- const ranges = versions.map(v => ({
181
+ const ranges = versions.flatMap(v => expandVersionChanges(v) || [{
102
182
  version: v.version,
103
183
  status: v.status,
104
184
  versionType: v.versionType,
105
185
  lessThan: v.lessThan,
106
186
  lessThanOrEqual: v.lessThanOrEqual,
107
- }));
108
- out.push({ packageName, vendor, product, ranges });
187
+ }]);
188
+ out.push({ packageName: packageNames[0] || null, packageNames, vendor, product, ranges });
109
189
  }
110
190
  return out;
111
191
  }
112
192
 
193
+ // Highest version-like upper bound across the affected ranges — the safe
194
+ // "upgrade to at least" target. (First-match was wrong for multi-branch
195
+ // records: struts 2.3.x fixed in 2.3.32 vs 2.5.x fixed in 2.5.10.1.)
113
196
  function fixVersionFromRanges(ranges) {
197
+ let best = null;
114
198
  for (const r of ranges) {
115
- if (r.lessThan) return r.lessThan;
116
- if (r.lessThanOrEqual) return r.lessThanOrEqual;
199
+ for (const u of [r.lessThan, r.lessThanOrEqual]) {
200
+ if (!versionLikeBound(u)) continue;
201
+ const s = String(u).trim();
202
+ if (!best || compareMavenVersions(s, best) > 0) best = s;
203
+ }
117
204
  }
118
- return null;
205
+ return best;
119
206
  }
120
207
 
121
208
  function extractMavenRelevantCve(json) {
@@ -160,8 +247,9 @@ function addToIndex(index, cve) {
160
247
  vendor: a.vendor,
161
248
  product: a.product,
162
249
  };
163
- if (a.packageName) {
164
- (index.byPackageName[a.packageName.toLowerCase()] ||= []).push(entry);
250
+ const names = a.packageNames?.length ? a.packageNames : (a.packageName ? [a.packageName] : []);
251
+ for (const n of names) {
252
+ (index.byPackageName[String(n).toLowerCase()] ||= []).push(entry);
165
253
  }
166
254
  if (a.product) {
167
255
  (index.byProduct[a.product] ||= []).push(entry);
@@ -369,4 +457,7 @@ module.exports = {
369
457
  ensureCveIndex,
370
458
  extractMavenRelevantCve,
371
459
  isMavenRelevant,
460
+ curatedCoordsFor,
461
+ newIndex,
462
+ addToIndex,
372
463
  };
package/lib/cve-report.js CHANGED
@@ -403,11 +403,20 @@ function renderDefinedIn(dep, srcRoot) {
403
403
  const paths = (dep.manifestPaths && dep.manifestPaths.length) ? dep.manifestPaths
404
404
  : (dep.pomPaths && dep.pomPaths.length) ? dep.pomPaths
405
405
  : [];
406
- if (!paths.length) return "";
407
- const rel = paths.map(p => makeRelative(p, srcRoot));
408
- const shown = rel.slice(0, 3).map(p => `<code>${esc(p)}</code>`).join(", ");
409
- const more = rel.length > 3 ? ` <span class="defined-more">+${rel.length - 3} more</span>` : "";
410
- return `<div class="defined-in"><span class="defined-label">defined in:</span> ${shown}${more}</div>`;
406
+ let definedLine = "";
407
+ if (paths.length) {
408
+ const rel = paths.map(p => makeRelative(p, srcRoot));
409
+ const shown = rel.slice(0, 3).map(p => `<code>${esc(p)}</code>`).join(", ");
410
+ const more = rel.length > 3 ? ` <span class="defined-more">+${rel.length - 3} more</span>` : "";
411
+ definedLine = `<div class="defined-in"><span class="defined-label">defined in:</span> ${shown}${more}</div>`;
412
+ }
413
+ // A versionless declared dep (typical Spring Boot / Gradle) gets its version from an import
414
+ // BOM, not the manifest it's declared in. Disclose that so 6.0.3 doesn't read as fabricated.
415
+ const vs = dep.versionSource;
416
+ const versionLine = (vs && vs.via === "bom" && vs.bom)
417
+ ? `<div class="defined-in"><span class="defined-label">version managed by:</span> <code>${esc(vs.bom)}</code> (BOM)</div>`
418
+ : "";
419
+ return definedLine + versionLine;
411
420
  }
412
421
 
413
422
  function makeRelative(absPath, srcRoot) {
@@ -1493,7 +1502,7 @@ function renderLicenseChapter(licenseResults) {
1493
1502
  return intro + blocks;
1494
1503
  }
1495
1504
 
1496
- function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs = [], resolvedDeps, projectInfo, warnings, parsedManifests = [], diff = null, wrapTables = true }) {
1505
+ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, certFindings, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs = [], resolvedDeps, projectInfo, warnings, parsedManifests = [], diff = null, wrapTables = true }) {
1497
1506
  setRenderCtx({ srcRoot: projectInfo?.src || null });
1498
1507
  // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1499
1508
  cveMatches = mergeMatches(cveMatches || []);
@@ -1533,6 +1542,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1533
1542
  const unmanagedContent = renderUnmanagedInventory(unmanagedInventory, projectInfo?.src);
1534
1543
  const vendoredJsInv = vendoredJsInventory || [];
1535
1544
  const vendoredJsContent = renderVendoredJsInventory(vendoredJsInv, projectInfo?.src);
1545
+ const certInv = certFindings || [];
1546
+ const certContent = renderCertificatesChapter(certInv, projectInfo?.src);
1536
1547
  const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP, ...embMatchesFP]);
1537
1548
 
1538
1549
  const toolbar = `<div class="toolbar">
@@ -1565,6 +1576,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1565
1576
  const embTotal = embeddedInventory.length;
1566
1577
  const unmTotal = unmanagedInventory.length;
1567
1578
  const vjsTotal = vendoredJsInv.length;
1579
+ const certTotal = certInv.length;
1580
+ const certPrivTotal = certInv.filter(c => c.kind === "private-key").length;
1568
1581
  const excTotal = excludedDirs?.length || 0;
1569
1582
  // CVE root header breakdown: direct vs indirect (transitive) production vulns + the dev count.
1570
1583
  const prodDirect = prodMatchesActive.filter(m => m.dep?.scope !== "transitive").length;
@@ -1581,12 +1594,13 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1581
1594
 
1582
1595
  // Root 2 — Unmanaged / unversioned components (binaries + vendored JS, no package manager).
1583
1596
  const binRoot = majorSection(
1584
- `2. Unmanaged / unversioned components (${embTotal} embedded, ${unmTotal} native, ${vjsTotal} vendored JS)`,
1597
+ `2. Unmanaged / unversioned components (${embTotal} embedded, ${unmTotal} native, ${vjsTotal} vendored JS, ${certTotal} crypto)`,
1585
1598
  (embTotal ? minorSection(`2.1 Embedded binaries — JAR/WAR/EAR (${embTotal})`, embeddedContent, { id: "ch1e", open: embTotal <= 50 }) : "") +
1586
1599
  (unmTotal ? minorSection(`2.2 Unmanaged / vendored native binaries (${unmTotal})`, unmanagedContent, { id: "ch1c", open: unmTotal <= 50 }) : "") +
1587
- (vjsTotal ? minorSection(`2.3 Unmanaged / vendored JavaScript (${vjsTotal})`, vendoredJsContent, { id: "ch1d", open: vjsTotal <= 50 }) : "") ||
1588
- `<div class="empty">No unmanaged binaries or vendored JavaScript found.</div>`,
1589
- { id: "chbin", open: (embTotal + unmTotal + vjsTotal) > 0 });
1600
+ (vjsTotal ? minorSection(`2.3 Unmanaged / vendored JavaScript (${vjsTotal})`, vendoredJsContent, { id: "ch1d", open: vjsTotal <= 50 }) : "") +
1601
+ (certTotal ? minorSection(`2.4 Certificates &amp; key material (${certTotal}${certPrivTotal ? `, ${certPrivTotal} private key${certPrivTotal > 1 ? "s" : ""}` : ""})`, certContent, { id: "ch1f", open: certTotal <= 50 }) : "") ||
1602
+ `<div class="empty">No unmanaged binaries, vendored JavaScript or committed crypto material found.</div>`,
1603
+ { id: "chbin", open: (embTotal + unmTotal + vjsTotal + certTotal) > 0 });
1590
1604
 
1591
1605
  // Root 3 — Maintenance / lifecycle (EOL + obsolete + outdated).
1592
1606
  const maintRoot = majorSection(
@@ -1618,10 +1632,11 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1618
1632
  { id: "ch3", label: `Dev (${devStats.total})` },
1619
1633
  ...(fpTotal ? [{ id: "ch9", label: `Likely FP (${fpTotal})` }] : []),
1620
1634
  ] },
1621
- { id: "chbin", label: `2. Unmanaged components (${embTotal + unmTotal + vjsTotal})`, sub: [
1635
+ { id: "chbin", label: `2. Unmanaged components (${embTotal + unmTotal + vjsTotal + certTotal})`, sub: [
1622
1636
  ...(embTotal ? [{ id: "ch1e", label: `Embedded (${embTotal})` }] : []),
1623
1637
  ...(unmTotal ? [{ id: "ch1c", label: `Native (${unmTotal})` }] : []),
1624
1638
  ...(vjsTotal ? [{ id: "ch1d", label: `Vendored JS (${vjsTotal})` }] : []),
1639
+ ...(certTotal ? [{ id: "ch1f", label: `Crypto (${certTotal})` }] : []),
1625
1640
  ] },
1626
1641
  { id: "chmaint", label: `3. Maintenance (${eolTotal + obsTotal + outTotal})`, sub: [
1627
1642
  { id: "ch4", label: `EOL (${eolTotal})` },
@@ -1813,6 +1828,48 @@ function renderVendoredJsInventory(inventory, srcRoot) {
1813
1828
  return intro + `<table><thead><tr><th>Library</th><th>Version</th><th>File</th><th>Detection</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
1814
1829
  }
1815
1830
 
1831
+ // Certificate / key-material inventory chapter (2.4). Every committed certificate,
1832
+ // private/public key and keystore the scan found, with crypto-hygiene findings.
1833
+ // 100% offline (built-in crypto.X509Certificate + header parsing — no network).
1834
+ const CERT_ISSUE_LABEL = {
1835
+ "private-key-committed": "private key committed",
1836
+ "public-key-committed": "public key",
1837
+ "keystore-committed": "keystore",
1838
+ "cert-expired": "EXPIRED",
1839
+ "cert-expiring": "expiring soon",
1840
+ "cert-weak-key": "weak key",
1841
+ "cert-weak-signature": "weak signature",
1842
+ "cert-self-signed": "self-signed",
1843
+ };
1844
+ const CERT_SEV_BG = { critical: "#7c0008", high: "#b91c1c", medium: "#ea580c", low: "#6b7280", info: "#6b7280" };
1845
+
1846
+ function renderCertificatesChapter(inventory, srcRoot) {
1847
+ if (!inventory || !inventory.length) return `<div class="empty">No committed certificates or key material found.</div>`;
1848
+ const priv = inventory.filter(c => c.kind === "private-key").length;
1849
+ const pub = inventory.filter(c => c.kind === "public-key").length;
1850
+ const expired = inventory.filter(c => c.issues.some(i => i.type === "cert-expired")).length;
1851
+ const intro = `<div class="fp-intro">Cryptographic material committed into the source tree — X.509 certificates, <strong>private &amp; public keys</strong> (PEM / OpenSSH every algorithm / PuTTY / PGP / one-line SSH) and Java/PKCS#12 keystores. Each key is labelled <strong>private</strong> (a committed secret — critical) or <strong>public</strong> (inventory). Detected and parsed entirely offline (built-in X.509 parser; no network, no decryption). <strong>${priv}</strong> private key${priv === 1 ? "" : "s"}, <strong>${pub}</strong> public key${pub === 1 ? "" : "s"}, <strong>${expired}</strong> expired certificate${expired === 1 ? "" : "s"}.</div>`;
1852
+ const typePill = c => c.kind === "private-key" ? pill("🔑 PRIVATE KEY", CERT_SEV_BG.critical)
1853
+ : c.kind === "public-key" ? pill("public key", "#2563eb")
1854
+ : c.kind === "keystore" ? pill("keystore", "#b45309")
1855
+ : pill("certificate", "#475569");
1856
+ const rows = inventory.map(c => {
1857
+ let rel = c.path;
1858
+ if (srcRoot) { try { rel = path.relative(srcRoot, c.path); } catch { /* keep abs */ } }
1859
+ const algo = `${esc(c.algorithm || "?")}${c.bits ? " " + esc(String(c.bits)) : ""}`;
1860
+ let detail;
1861
+ if (c.kind === "certificate") {
1862
+ const exp = c.notAfter ? `exp ${esc(c.notAfter.slice(0, 10))}${c.daysUntilExpiry != null ? ` (${c.daysUntilExpiry}d)` : ""}` : "";
1863
+ detail = `${esc(c.subject || "?")} <span style="color:#6b7280">← ${esc(c.issuer || "?")}</span><br><span style="color:#6b7280">${exp}</span>`;
1864
+ } else {
1865
+ detail = `<span style="color:#6b7280">${esc(c.format || "")}${c.encrypted === true ? " · encrypted" : c.encrypted === false ? " · UNENCRYPTED" : ""}${c.count > 1 ? ` · ${c.count} keys` : ""}</span>`;
1866
+ }
1867
+ const status = (c.issues || []).map(i => pill(CERT_ISSUE_LABEL[i.type] || i.type, CERT_SEV_BG[i.severity] || "#6b7280")).join(" ");
1868
+ return `<tr><td class="dep"><code class="path">${esc(rel)}</code></td><td>${typePill(c)}</td><td>${algo}</td><td>${detail}</td><td>${status}</td><td><code class="path" style="color:#6b7280">${esc((c.sha256 || "").slice(0, 16))}…</code></td></tr>`;
1869
+ }).join("\n");
1870
+ return intro + `<table><thead><tr><th>File</th><th>Type</th><th>Algorithm</th><th>Details</th><th>Findings</th><th>SHA-256</th></tr></thead><tbody>${rows}</tbody></table>`;
1871
+ }
1872
+
1816
1873
  function renderExcludedDirs(excludedDirs) {
1817
1874
  if (!excludedDirs || !excludedDirs.length) return `<div class="empty">No directories were excluded from the scan.</div>`;
1818
1875
  const byDefault = excludedDirs.filter(e => e.type === "default").length;
@@ -2344,12 +2401,12 @@ ${WORD_SECTION_PR}
2344
2401
  // Render the HTML and/or Word report. Callers pass explicit target paths;
2345
2402
  // a null/omitted path skips that format (so `--report-html` alone writes only HTML).
2346
2403
  // Back-compat: `outputDir` still writes both under the default file names.
2347
- async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests, diff }) {
2404
+ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, certFindings, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests, diff }) {
2348
2405
  if (outputDir) {
2349
2406
  if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
2350
2407
  if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
2351
2408
  }
2352
- const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, warnings, parsedManifests, diff };
2409
+ const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, certFindings, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, warnings, parsedManifests, diff };
2353
2410
  const written = { htmlPath: null, docPath: null };
2354
2411
  if (htmlPath) {
2355
2412
  await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
@@ -30,6 +30,9 @@ function depBrief(dep) {
30
30
  provenance: dep.provenance || "manifest",
31
31
  purl: purlFor(dep),
32
32
  manifestPaths: dep.manifestPaths || dep.pomPaths || [],
33
+ // When the version was backfilled from an import/platform BOM, record which BOM
34
+ // supplied it — { via: "bom", bom: "g:a:v" } — so the finding is traceable.
35
+ versionSource: dep.versionSource || null,
33
36
  };
34
37
  }
35
38
 
@@ -66,7 +69,7 @@ const SEV = ["critical", "high", "medium", "low", "none", "unknown"];
66
69
  */
67
70
  function buildFindings(payload = {}) {
68
71
  const {
69
- cveMatches = [], retireMatches = [], vendoredJsInventory = [], eolResults = [], obsoleteResults = [],
72
+ cveMatches = [], retireMatches = [], vendoredJsInventory = [], certFindings = [], eolResults = [], obsoleteResults = [],
70
73
  outdatedResults = [], licenseResults = null, resolvedDeps, projectInfo = {}, toolVersion = "0",
71
74
  typosquats = [], excludedDirs = [], diff = null,
72
75
  } = payload;
@@ -105,6 +108,8 @@ function buildFindings(payload = {}) {
105
108
  unmanaged: unmanaged.length,
106
109
  embedded: embedded.length,
107
110
  vendoredJs: vendoredJsInventory.length,
111
+ certificates: certFindings.length,
112
+ certPrivateKeys: certFindings.filter(c => c.kind === "private-key").length,
108
113
  suppressed: cveMatches.filter(m => m.suppressed).length,
109
114
  malicious: cveMatches.filter(m => m.malicious && !m.suppressed).length,
110
115
  typosquat: typosquats.length,
@@ -119,6 +124,7 @@ function buildFindings(payload = {}) {
119
124
  unmanaged,
120
125
  embedded,
121
126
  vendoredJs: vendoredJsInventory,
127
+ certificates: certFindings,
122
128
  typosquat: typosquats,
123
129
  excludedDirs,
124
130
  // Differential audit vs a --baseline document (summary + per-category deltas).
package/lib/maven-bom.js CHANGED
@@ -48,6 +48,12 @@ function collectImportBoms(propsByPom) {
48
48
  /**
49
49
  * Resolve each import BOM to its managed-version table and merge them into one map.
50
50
  * First BOM to manage a given g:a wins (mirrors Maven's declaration-order precedence).
51
+ *
52
+ * Each value is `{ version, bom }` where `bom` is the `groupId:artifactId:version` of the
53
+ * TOP-LEVEL platform/import BOM that supplied the version (e.g. spring-boot-dependencies),
54
+ * not the nested BOM it may import (spring-batch-bom) — that's the coordinate the user wrote
55
+ * in `platform(...)`/`<scope>import</scope>` and recognizes. Carried so backfillVersions can
56
+ * record per-dep provenance ("version managed by <bom>").
51
57
  */
52
58
  async function resolveBomManagedVersions(boms, opts = {}) {
53
59
  const effectivePom = opts.effectivePom || transitive.effectivePom;
@@ -57,11 +63,12 @@ async function resolveBomManagedVersions(boms, opts = {}) {
57
63
  try { eff = await effectivePom(bom.groupId, bom.artifactId, bom.version, opts); }
58
64
  catch { eff = null; }
59
65
  if (!eff || !eff.depMgmt) continue;
66
+ const bomCoord = `${bom.groupId}:${bom.artifactId}:${bom.version}`;
60
67
  for (const d of eff.depMgmt) {
61
68
  if (!d.groupId || !d.artifactId) continue;
62
69
  const k = `${d.groupId}:${d.artifactId}`;
63
70
  if (map.has(k)) continue;
64
- if (d.version && !/\$\{/.test(String(d.version))) map.set(k, String(d.version));
71
+ if (d.version && !/\$\{/.test(String(d.version))) map.set(k, { version: String(d.version), bom: bomCoord });
65
72
  }
66
73
  }
67
74
  return map;
@@ -69,7 +76,10 @@ async function resolveBomManagedVersions(boms, opts = {}) {
69
76
 
70
77
  /**
71
78
  * Fill the version of every Maven dep that has no concrete version (null or `${…}`)
72
- * from the BOM-managed map. Mutates the resolvedDeps Map entries in place.
79
+ * from the BOM-managed map. Mutates the resolvedDeps Map entries in place. Each filled
80
+ * dep is stamped with `versionSource = { via: "bom", bom: "<coord>" }` so the report can
81
+ * disclose that the version came from a BOM (e.g. "version managed by spring-boot-dependencies")
82
+ * rather than the manifest it's declared in — the usual versionless Spring Boot / Gradle case.
73
83
  * @returns number of deps filled
74
84
  */
75
85
  function backfillVersions(resolvedDeps, mgmtMap) {
@@ -78,11 +88,13 @@ function backfillVersions(resolvedDeps, mgmtMap) {
78
88
  if (dep.ecosystem !== "maven") continue;
79
89
  if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
80
90
  if (dep.version && !/\$\{/.test(String(dep.version))) continue; // already concrete
81
- const v = mgmtMap.get(`${dep.groupId}:${dep.artifactId}`);
82
- if (!v) continue;
91
+ const entry = mgmtMap.get(`${dep.groupId}:${dep.artifactId}`);
92
+ if (!entry) continue;
93
+ const v = entry.version;
83
94
  dep.version = v;
84
95
  if (!Array.isArray(dep.versions)) dep.versions = [];
85
96
  if (!dep.versions.includes(v)) dep.versions.push(v);
97
+ dep.versionSource = { via: "bom", bom: entry.bom };
86
98
  filled++;
87
99
  }
88
100
  return filled;