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 +1 -0
- package/fad-checker.js +1 -1
- package/lib/cve-match.js +29 -10
- package/lib/cve-report.js +96 -13
- package/lib/osv.js +19 -13
- package/package.json +1 -1
- package/test/cve-match.test.js +40 -0
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
|
|
24
|
-
*
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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],
|
|
1501
|
-
'text/plain': new Blob([
|
|
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')
|
|
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 =
|
|
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
|
-
//
|
|
301
|
-
// a
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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.
|
|
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",
|
package/test/cve-match.test.js
CHANGED
|
@@ -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, {});
|