fad-checker 1.0.5 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -85,6 +85,7 @@ For the deep dive — pipeline stages, the resolved-deps Map shape, report struc
85
85
  - **No `process.exit(1)` mid-pipeline**: a parse/rewrite failure for one POM logs and continues so the summary still prints.
86
86
  - **HTML report is self-contained**: inline CSS, no external assets. The `.doc` variant is the same HTML with Office XML namespace meta tags — Word opens it natively.
87
87
  - **Map keys are ecosystem-namespaced**: Maven uses `g:a`, npm uses `npm:name`. They never collide so they share one resolved-deps Map.
88
+ - **Every distinct version is scanned, not just the highest**: when profiles/modules pin the same `g:a` to different versions, the resolved-dep entry keeps `version` = highest (representative for display/EOL/outdated) but `versions` = all distinct concrete versions. CVE matching (`matchOne`) and OSV (`queryOsvForDeps`) iterate `versions` so a vuln affecting only a lower-versioned profile variant isn't missed. Match dedup keys are `g:a:version|cve.id` (version included) to preserve per-version findings.
88
89
  - **Lockfile-only npm**: `package.json` without sibling `package-lock.json`/`yarn.lock` is intentionally skipped (its ranges aren't queryable) and reported in chapter 0.
89
90
  - **Source identifiers**: every match carries `source: "fad" | "osv" | "nvd" | "snyk" | "retire"` (or a `+`-joined combination).
90
91
 
package/fad-checker.js CHANGED
@@ -648,7 +648,7 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
648
648
  */
649
649
  function mergeBySource(existing, additions) {
650
650
  const byKey = new Map();
651
- const k = m => `${m.dep.groupId}:${m.dep.artifactId}|${m.cve.id}`;
651
+ const k = m => `${m.dep.groupId}:${m.dep.artifactId}:${m.dep.version}|${m.cve.id}`;
652
652
  for (const m of existing || []) byKey.set(k(m), { ...m, source: m.source || "fad" });
653
653
  for (const m of additions || []) {
654
654
  const key = k(m);
package/lib/cve-match.js CHANGED
@@ -20,10 +20,13 @@ function resolveDepVersion(rawVersion, allProps) {
20
20
 
21
21
  /**
22
22
  * Walk allPomMetadata.byPath, collect every {groupId,artifactId,version,scope}
23
- * triple, dedupe by groupId:artifactId keeping the highest version seen,
24
- * also include external parent POMs as scope='parent'.
23
+ * triple, dedupe by groupId:artifactId. `version` is the highest seen
24
+ * (representative for display/EOL/outdated), while `versions` keeps EVERY
25
+ * distinct concrete version (e.g. two profiles pinning the same g:a) so the
26
+ * CVE/OSV scanners check each one, not just the highest. Also includes
27
+ * external parent POMs as scope='parent'.
25
28
  *
26
- * Returns Map<key, { groupId, artifactId, version, scope, pomPaths }>
29
+ * Returns Map<key, { groupId, artifactId, version, versions, scope, pomPaths }>
27
30
  */
28
31
  function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
29
32
  const { ignoreTest, deps2Exclude } = opts;
@@ -43,14 +46,19 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
43
46
  const key = `${g}:${a}`;
44
47
  const existing = out.get(key);
45
48
  const isDev = scope === "test" || scope === "provided";
49
+ // A concrete (resolved, non-property) version worth scanning on its own.
50
+ const concrete = v && !/\$\{/.test(v) ? v : null;
46
51
  if (!existing) {
47
- out.set(key, { groupId: g, artifactId: a, version: v || null, scope, pomPaths: [pomPath], ecosystem: "maven", ecosystemType: "maven", isDev });
52
+ out.set(key, { groupId: g, artifactId: a, version: v || null, versions: concrete ? [concrete] : [], scope, pomPaths: [pomPath], ecosystem: "maven", ecosystemType: "maven", isDev });
48
53
  } else {
49
54
  if (!existing.pomPaths.includes(pomPath)) existing.pomPaths.push(pomPath);
50
55
  if (existing.scope === "test" && scope !== "test") existing.scope = scope;
51
56
  if (v && (!existing.version || compareMavenVersions(v, existing.version) > 0)) {
52
57
  existing.version = v;
53
58
  }
59
+ // Track every distinct concrete version (e.g. different profiles
60
+ // pinning the same g:a) so each gets scanned, not just the highest.
61
+ if (concrete && !existing.versions.includes(concrete)) existing.versions.push(concrete);
54
62
  // A dep is "dev" overall only if every occurrence is test/provided.
55
63
  if (!isDev) existing.isDev = false;
56
64
  }
@@ -77,6 +85,7 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
77
85
  if (!out.has(key)) {
78
86
  out.set(key, {
79
87
  groupId: p.groupId, artifactId: p.artifactId, version: p.version || null,
88
+ versions: p.version && !/\$\{/.test(p.version) ? [p.version] : [],
80
89
  scope: "parent", pomPaths: [pomPath], ecosystem: "maven", ecosystemType: "maven",
81
90
  });
82
91
  }
@@ -127,6 +136,7 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
127
136
  groupId: t.groupId,
128
137
  artifactId: t.artifactId,
129
138
  version: t.version,
139
+ versions: t.version && !/\$\{/.test(t.version) ? [t.version] : [],
130
140
  scope: "transitive",
131
141
  pomPaths: [],
132
142
  via: t.via,
@@ -151,12 +161,21 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
151
161
  function matchOne(dep, entries, confidence) {
152
162
  const matches = [];
153
163
  if (!entries) return matches;
164
+ // Scan every distinct version the dep resolves to (e.g. two profiles pinning
165
+ // the same g:a), not just the representative highest — otherwise a CVE that
166
+ // only affects a lower-versioned profile variant would be missed.
167
+ const versions = (dep.versions && dep.versions.length) ? dep.versions : [dep.version];
154
168
  for (const e of entries) {
155
- const affected = (e.ranges || []).some(r => {
156
- if (!dep.version) return r.status === "affected"; // unknown version → assume affected
157
- return isVersionAffected(dep.version, r);
158
- });
159
- if (affected) matches.push({ dep, cve: { ...e }, confidence });
169
+ for (const ver of versions) {
170
+ const affected = (e.ranges || []).some(r => {
171
+ if (!ver) return r.status === "affected"; // unknown version → assume affected
172
+ return isVersionAffected(ver, r);
173
+ });
174
+ if (affected) {
175
+ const vdep = ver === dep.version ? dep : { ...dep, version: ver };
176
+ matches.push({ dep: vdep, cve: { ...e }, confidence });
177
+ }
178
+ }
160
179
  }
161
180
  return matches;
162
181
  }
@@ -212,7 +231,7 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
212
231
  const seen = new Set();
213
232
  const deduped = [];
214
233
  for (const m of all) {
215
- const k = `${m.dep.groupId}:${m.dep.artifactId}|${m.cve.id}`;
234
+ const k = `${m.dep.groupId}:${m.dep.artifactId}:${m.dep.version}|${m.cve.id}`;
216
235
  if (seen.has(k)) continue;
217
236
  seen.add(k);
218
237
  deduped.push(m);
package/lib/cve-report.js CHANGED
@@ -132,6 +132,7 @@ code.path { font-family: ui-monospace, monospace; font-size: 0.9em; background:
132
132
  .exec-summary .exec-head { display: flex; align-items: center; gap: 12px; }
133
133
  .exec-summary .exec-level { font-size: 12px; font-weight: 700; letter-spacing: 1px; padding: 4px 12px; border-radius: 999px; }
134
134
  .exec-summary .exec-title { font-size: 18px; font-weight: 600; color: #111827; }
135
+ .exec-summary .exec-copy-btn { margin-left: auto; }
135
136
  .exec-summary .exec-meta { font-size: 12px; color: #6b7280; }
136
137
  .exec-summary .exec-bullets { margin: 4px 0 0; padding-left: 22px; font-size: 13px; color: #374151; line-height: 1.6; }
137
138
  .exec-summary.exec-critical { border-color: #dc2626; }
@@ -522,7 +523,7 @@ function renderDetailPanel(m) {
522
523
 
523
524
  // 7. Declared-in POMs
524
525
  if (dep.pomPaths?.length) {
525
- const items = dep.pomPaths.slice(0, 10).map(p => `<div>${esc(p)}</div>`).join("");
526
+ const items = dep.pomPaths.slice(0, 10).map(p => `<div>${esc(makeRelative(p, RENDER_CTX.srcRoot))}</div>`).join("");
526
527
  sections.push(section("Declared in",
527
528
  `<div class="via-list">${items}${dep.pomPaths.length > 10 ? `<div style="color:#9ca3af">+${dep.pomPaths.length - 10} more</div>` : ""}</div>`,
528
529
  { count: `${dep.pomPaths.length} POM${dep.pomPaths.length > 1 ? "s" : ""}`, open: false }));
@@ -1029,7 +1030,7 @@ function summaryCards(stats, eol, obs, out, extra = {}) {
1029
1030
  function mergeMatches(matches) {
1030
1031
  const byKey = new Map();
1031
1032
  for (const m of matches) {
1032
- const key = `${m.dep.groupId}:${m.dep.artifactId}|${m.cve.id}`;
1033
+ const key = `${m.dep.groupId}:${m.dep.artifactId}:${m.dep.version}|${m.cve.id}`;
1033
1034
  if (!byKey.has(key)) {
1034
1035
  // Deep-ish copy so we can mutate dep.viaPaths
1035
1036
  byKey.set(key, { ...m, dep: { ...m.dep, viaPaths: m.dep.viaPaths ? [...m.dep.viaPaths] : (m.dep.via ? [m.dep.via] : []) } });
@@ -1150,7 +1151,7 @@ function renderRetireTable(matches) {
1150
1151
  const rows = sorted.map(m => `<tr>
1151
1152
  <td>${badge((m.cve.severity || "MEDIUM").toUpperCase())}</td>
1152
1153
  <td class="dep">${esc(m.dep.artifactId)}<br><span style="color:#6b7280">${esc(m.dep.version || "?")}</span></td>
1153
- <td class="dep"><code class="path">${esc(m.dep.vendoredFile || "")}</code></td>
1154
+ <td class="dep"><code class="path">${esc(makeRelative(m.dep.vendoredFile || "", RENDER_CTX.srcRoot))}</code></td>
1154
1155
  <td class="cve">${m.cve.id?.startsWith("CVE-") ? `<a href="https://nvd.nist.gov/vuln/detail/${esc(m.cve.id)}" target="_blank" rel="noopener">${esc(m.cve.id)}</a>` : esc(m.cve.id)}</td>
1155
1156
  <td class="dep">${esc(m.cve.fixVersion || "—")}</td>
1156
1157
  <td class="desc">${esc(shortDesc(m.cve.description))}</td>
@@ -1161,7 +1162,22 @@ function renderRetireTable(matches) {
1161
1162
  </table>`;
1162
1163
  }
1163
1164
 
1164
- function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats, eolResults, obsoleteResults, outdatedResults, warnings, totalDeps, topCriticalMatches }) {
1165
+ /**
1166
+ * One "vulnerable to" clause for a match, in the format requested for the
1167
+ * Word-pasteable summary: prefer the primary CWE ("CWE-79 : Cross-site
1168
+ * Scripting"), fall back to the CVE id when no weakness is recorded.
1169
+ */
1170
+ function execWeakness(m) {
1171
+ const cwes = Array.isArray(m.cve.cwes) ? m.cve.cwes : [];
1172
+ const primary = cwes.find(c => cweName(c)) || cwes[0];
1173
+ if (primary) {
1174
+ const label = cweName(primary);
1175
+ return label ? `${primary} : ${label}` : primary;
1176
+ }
1177
+ return m.cve.id || "an unspecified weakness";
1178
+ }
1179
+
1180
+ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats, eolResults, obsoleteResults, outdatedResults, warnings, totalDeps, topCriticalMatches, interactive = false }) {
1165
1181
  const eolN = eolResults?.length || 0;
1166
1182
  const obsN = obsoleteResults?.length || 0;
1167
1183
  const outN = outdatedResults?.length || 0;
@@ -1204,10 +1220,59 @@ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats,
1204
1220
  }).join("")}</ul>
1205
1221
  </div>` : "";
1206
1222
 
1223
+ // ---- Word-pasteable copy payload (built only for the interactive HTML) ----
1224
+ let copyUi = "";
1225
+ if (interactive) {
1226
+ const stripTags = s => s.replace(/<[^>]+>/g, "");
1227
+ const plainBullets = bullets.map(stripTags);
1228
+ const top = topCriticalMatches || [];
1229
+ const topSentences = top.map(m => ({
1230
+ lib: depDisplayName(m.dep),
1231
+ ver: m.dep.version || "?",
1232
+ weakness: execWeakness(m),
1233
+ crit: (m.cve.severity || "HIGH").toUpperCase(),
1234
+ }));
1235
+
1236
+ // Plain-text version (text/plain) — exact bullet format requested.
1237
+ const plainLines = [];
1238
+ plainLines.push(`EXECUTIVE SUMMARY — ${level}`);
1239
+ plainLines.push(`${totalDeps} dependencies scanned`);
1240
+ plainLines.push("");
1241
+ if (plainBullets.length) for (const b of plainBullets) plainLines.push(`- ${b}`);
1242
+ else plainLines.push("- No findings detected.");
1243
+ if (topSentences.length) {
1244
+ plainLines.push("");
1245
+ plainLines.push(`Top ${topSentences.length} most critical:`);
1246
+ for (const t of topSentences) {
1247
+ plainLines.push(`- The library "${t.lib}" version "${t.ver}" is vulnerable to "${t.weakness}" ( ${t.crit} )`);
1248
+ }
1249
+ }
1250
+ const plainText = plainLines.join("\n");
1251
+
1252
+ // Rich version (text/html) — inline styles so Word keeps the formatting.
1253
+ const richBullets = plainBullets.length
1254
+ ? `<ul style="margin:4px 0 0; padding-left:22px;">${plainBullets.map(b => `<li style="margin:2px 0;">${esc(b)}</li>`).join("")}</ul>`
1255
+ : `<p style="margin:4px 0;">No findings detected.</p>`;
1256
+ const richTop = topSentences.length
1257
+ ? `<p style="font-weight:bold; margin:12px 0 4px;">Top ${topSentences.length} most critical:</p>
1258
+ <ul style="margin:0; padding-left:22px;">${topSentences.map(t =>
1259
+ `<li style="margin:3px 0;">The library "<b>${esc(t.lib)}</b>" version "<b>${esc(t.ver)}</b>" is vulnerable to "<b>${esc(t.weakness)}</b>" ( ${esc(t.crit)} )</li>`).join("")}</ul>`
1260
+ : "";
1261
+ const richHtml = `<div style="font-family:Calibri,Arial,sans-serif; font-size:11pt; color:#1f2937;">`
1262
+ + `<p style="font-size:14pt; font-weight:bold; margin:0 0 2px;">Executive Summary — ${esc(level)}</p>`
1263
+ + `<p style="margin:0 0 8px; color:#4b5563;">${esc(totalDeps)} dependencies scanned</p>`
1264
+ + richBullets + richTop + `</div>`;
1265
+
1266
+ copyUi = `<button class="btn-copy exec-copy-btn" type="button" title="Copy the executive summary — paste it into Word with formatting preserved">📋 Copy summary</button>`
1267
+ + `<template class="exec-copy-rich">${richHtml}</template>`
1268
+ + `<textarea class="exec-copy-plain" aria-hidden="true" tabindex="-1" style="position:absolute;left:-9999px;top:0;width:1px;height:1px;opacity:0;">${esc(plainText)}</textarea>`;
1269
+ }
1270
+
1207
1271
  return `<div class="exec-summary exec-${esc(level.toLowerCase())}">
1208
1272
  <div class="exec-head">
1209
1273
  <span class="exec-level">${esc(level)}</span>
1210
1274
  <span class="exec-title">Executive Summary</span>
1275
+ ${copyUi}
1211
1276
  </div>
1212
1277
  <div class="exec-meta">${esc(totalDeps)} dependencies scanned</div>
1213
1278
  <ul class="exec-bullets">
@@ -1248,7 +1313,7 @@ function renderWarningItems(items, itemLimit) {
1248
1313
  const id = it.id || "";
1249
1314
  const paths = (it.manifestPaths || []).slice(0, 6);
1250
1315
  const pathsHtml = paths.length
1251
- ? `<div class="warn-defined">defined in: ${paths.map(p => `<code class="path">${esc(p)}</code>`).join(", ")}${it.manifestPaths.length > paths.length ? ` <span class="warn-defined-more">+${it.manifestPaths.length - paths.length} more</span>` : ""}</div>`
1316
+ ? `<div class="warn-defined">defined in: ${paths.map(p => `<code class="path">${esc(makeRelative(p, RENDER_CTX.srcRoot))}</code>`).join(", ")}${it.manifestPaths.length > paths.length ? ` <span class="warn-defined-more">+${it.manifestPaths.length - paths.length} more</span>` : ""}</div>`
1252
1317
  : "";
1253
1318
  return `<li><code>${esc(id)}</code>${pathsHtml}</li>`;
1254
1319
  }).join("");
@@ -1298,6 +1363,7 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
1298
1363
  eolResults, obsoleteResults, outdatedResults,
1299
1364
  warnings, totalDeps: resolvedDeps?.size || 0,
1300
1365
  topCriticalMatches: pickTopCriticalMatches(prodMatchesActive, retireMatches, 5),
1366
+ interactive: wrapTables, // copy button + scripts only in the HTML output
1301
1367
  });
1302
1368
 
1303
1369
  // (C) Table of contents — rendered as a sticky nav. Only enumerates the
@@ -1488,31 +1554,32 @@ const COPY_SCRIPT = `
1488
1554
  btn.classList.remove(cls);
1489
1555
  }, 1500);
1490
1556
  }
1491
- async function copyTable(table, btn){
1492
- var clone = inlineStylesForWord(table);
1493
- var html = wordWrapper(clone.outerHTML);
1494
- var tsv = tableToTsv(table);
1557
+ // Write rich HTML (+ plain-text fallback) to the clipboard. Shared by the
1558
+ // table buttons and the executive-summary button. richInner is the HTML
1559
+ // fragment; we wrap it with the Office namespaces so Word keeps formatting.
1560
+ async function writeClipboard(richInner, plain, btn){
1561
+ var html = wordWrapper(richInner);
1495
1562
  // Modern path: ClipboardItem with text/html + text/plain
1496
1563
  try {
1497
1564
  if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
1498
1565
  await navigator.clipboard.write([
1499
1566
  new ClipboardItem({
1500
- 'text/html': new Blob([html], { type: 'text/html' }),
1501
- 'text/plain': new Blob([tsv], { type: 'text/plain' })
1567
+ 'text/html': new Blob([html], { type: 'text/html' }),
1568
+ 'text/plain': new Blob([plain], { type: 'text/plain' })
1502
1569
  })
1503
1570
  ]);
1504
1571
  flash(btn, 'flashing-ok', '✓ Copied!');
1505
1572
  return;
1506
1573
  }
1507
1574
  } catch(e) { /* fall through to legacy path */ }
1508
- // Legacy path: select clone, execCommand('copy') — gets the HTML
1575
+ // Legacy path: select a contenteditable clone, execCommand('copy')
1509
1576
  try {
1510
1577
  var temp = document.createElement('div');
1511
1578
  temp.setAttribute('contenteditable', 'true');
1512
1579
  temp.style.position = 'fixed';
1513
1580
  temp.style.left = '-9999px';
1514
1581
  temp.style.top = '0';
1515
- temp.innerHTML = clone.outerHTML;
1582
+ temp.innerHTML = richInner;
1516
1583
  document.body.appendChild(temp);
1517
1584
  var range = document.createRange();
1518
1585
  range.selectNodeContents(temp);
@@ -1527,7 +1594,14 @@ const COPY_SCRIPT = `
1527
1594
  flash(btn, 'flashing-err', '✗ Copy failed');
1528
1595
  }
1529
1596
  }
1597
+ function copyTable(table, btn){
1598
+ var clone = inlineStylesForWord(table);
1599
+ return writeClipboard(clone.outerHTML, tableToTsv(table), btn);
1600
+ }
1530
1601
  document.querySelectorAll('.btn-copy').forEach(function(btn){
1602
+ // The executive-summary button is wired separately (it copies a prose
1603
+ // summary, not a table).
1604
+ if (btn.classList.contains('exec-copy-btn')) return;
1531
1605
  btn.addEventListener('click', function(e){
1532
1606
  e.stopPropagation();
1533
1607
  var wrap = btn.closest('.table-wrap');
@@ -1535,6 +1609,15 @@ const COPY_SCRIPT = `
1535
1609
  if (table) copyTable(table, btn);
1536
1610
  });
1537
1611
  });
1612
+ var execBtn = document.querySelector('.exec-copy-btn');
1613
+ if (execBtn) execBtn.addEventListener('click', function(e){
1614
+ e.stopPropagation();
1615
+ var tpl = document.querySelector('.exec-copy-rich');
1616
+ var ta = document.querySelector('.exec-copy-plain');
1617
+ var rich = tpl ? (tpl.innerHTML || '') : '';
1618
+ var plain = ta ? (ta.value || '') : '';
1619
+ writeClipboard(rich, plain, execBtn);
1620
+ });
1538
1621
  })();
1539
1622
  </script>`;
1540
1623
 
package/lib/osv.js CHANGED
@@ -295,20 +295,26 @@ function runMatches(deps, indexMap, detailById) {
295
295
  async function queryOsvForDeps(resolvedDeps, opts = {}) {
296
296
  const deps = [];
297
297
  for (const d of resolvedDeps.values()) {
298
- if (!d.version || /\$\{|SNAPSHOT/i.test(d.version)) continue;
299
298
  if (d.scope === "parent") continue; // parents don't have OSV entries typically
300
- // npm version specifiers like "^1.0.0" can't be queried against OSV — need
301
- // a concrete version. Lockfiles always provide one; package.json-only deps
302
- // are skipped here.
303
- if (d.ecosystem === "npm" && /^[\^~>=<*]|^(latest|next|workspace:|git\+|file:|link:)/.test(d.version)) continue;
304
- deps.push({
305
- groupId: d.groupId, artifactId: d.artifactId, version: d.version,
306
- scope: d.scope, via: d.via, depth: d.depth, pomPaths: d.pomPaths,
307
- manifestPaths: d.manifestPaths,
308
- ecosystem: d.ecosystem || "maven",
309
- ecosystemType: d.ecosystemType,
310
- isDev: !!d.isDev,
311
- });
299
+ // Query every distinct concrete version (e.g. two profiles pinning the
300
+ // same g:a), not just the representative highest otherwise a vuln that
301
+ // only affects a lower-versioned variant would be missed.
302
+ const versions = (d.versions && d.versions.length) ? d.versions : [d.version];
303
+ for (const ver of versions) {
304
+ if (!ver || /\$\{|SNAPSHOT/i.test(ver)) continue;
305
+ // npm version specifiers like "^1.0.0" can't be queried against OSV —
306
+ // need a concrete version. Lockfiles always provide one; package.json-
307
+ // only deps are skipped here.
308
+ if (d.ecosystem === "npm" && /^[\^~>=<*]|^(latest|next|workspace:|git\+|file:|link:)/.test(ver)) continue;
309
+ deps.push({
310
+ groupId: d.groupId, artifactId: d.artifactId, version: ver,
311
+ scope: d.scope, via: d.via, depth: d.depth, pomPaths: d.pomPaths,
312
+ manifestPaths: d.manifestPaths,
313
+ ecosystem: d.ecosystem || "maven",
314
+ ecosystemType: d.ecosystemType,
315
+ isDev: !!d.isDev,
316
+ });
317
+ }
312
318
  }
313
319
  return queryBatch(deps, opts);
314
320
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Fucking Autonomous Dependency Checker — Scans ALL pom/bom, npm/Yarn stuff and vendored JavaScript in a source tree, deal with private deps, multi modules & produces 1 HTML/DOC report with CVE, EOL, obsolete, outdated deps & fix recos",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/n8tz/fad-checker",
@@ -133,6 +133,46 @@ test("collectResolvedDeps keeps highest version on conflict", async () => {
133
133
  if (lang) assert.equal(lang.version, "3.12.0");
134
134
  });
135
135
 
136
+ test("collectResolvedDeps records every distinct version (e.g. two profiles)", () => {
137
+ const depXml = (g, a, v) => ({ groupId: [g], artifactId: [a], version: [v], scope: ["compile"] });
138
+ const store = { byPath: { a: {}, b: {} }, byId: {} };
139
+ // Same g:a pinned to different versions in two modules/profiles.
140
+ const props = {
141
+ a: { properties: {}, dependencies: [depXml("com.x", "y", "1.0.0")], dependencyManagement: [] },
142
+ b: { properties: {}, dependencies: [depXml("com.x", "y", "2.0.0")], dependencyManagement: [] },
143
+ };
144
+ const deps = collectResolvedDeps(store, props, {});
145
+ const y = deps.get("com.x:y");
146
+ assert.ok(y, "the dep is collected once, keyed by g:a");
147
+ assert.equal(y.version, "2.0.0", "representative version is still the highest");
148
+ assert.deepEqual([...y.versions].sort(), ["1.0.0", "2.0.0"], "both distinct versions are tracked");
149
+ });
150
+
151
+ test("matchDepsAgainstCves scans every distinct version, not just the highest", () => {
152
+ // A CVE that only affects the OLD version. Profiles pin 2.14.0 (vulnerable)
153
+ // and 2.20.0 (patched); the highest is patched, so deduping on max would
154
+ // hide the real exposure. Each distinct version must be scanned.
155
+ const idx = {
156
+ byPackageName: {
157
+ "org.apache.logging.log4j:log4j-core": [
158
+ { id: "CVE-OLD-0001", severity: "CRITICAL", vendor: "apache", product: "log4j-core",
159
+ ranges: [{ version: "2.0", lessThan: "2.15.0", status: "affected" }] },
160
+ ],
161
+ },
162
+ byProduct: {},
163
+ };
164
+ const deps = new Map([
165
+ ["org.apache.logging.log4j:log4j-core", {
166
+ groupId: "org.apache.logging.log4j", artifactId: "log4j-core",
167
+ version: "2.20.0", versions: ["2.14.0", "2.20.0"], scope: "compile", pomPaths: [],
168
+ }],
169
+ ]);
170
+ const matches = matchDepsAgainstCves(deps, idx);
171
+ assert.equal(matches.length, 1, "vulnerable 2.14.0 must be flagged despite 2.20.0 being highest");
172
+ assert.equal(matches[0].cve.id, "CVE-OLD-0001");
173
+ assert.equal(matches[0].dep.version, "2.14.0", "match carries the actually-vulnerable version");
174
+ });
175
+
136
176
  test("collectResolvedDeps --ignore-test drops test-scope deps", async () => {
137
177
  const { store, props } = await pipeline(COMPLEX);
138
178
  const withTest = collectResolvedDeps(store, props, {});