fad-checker 2.2.0 → 2.2.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.
- package/README.md +32 -117
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +1 -0
- package/fad-checker.js +122 -16
- package/lib/codecs/npm.codec.js +2 -2
- package/lib/cve-match.js +10 -5
- package/lib/cve-report.js +65 -20
- package/lib/embedded.js +57 -0
- package/lib/json-export.js +7 -1
- package/lib/manifest-copy.js +75 -0
- package/lib/maven-bom.js +104 -0
- package/lib/outdated.js +19 -7
- package/lib/retire.js +95 -10
- package/lib/scan-completeness.js +15 -6
- package/lib/transitive.js +9 -2
- package/lib/ui.js +4 -3
- package/lib/version-overlay.js +193 -0
- package/package.json +1 -1
- package/fad-checker-report/cve-report.doc +0 -1168
- package/fad-checker-report/cve-report.html +0 -1293
package/lib/cve-report.js
CHANGED
|
@@ -13,6 +13,7 @@ const path = require("path");
|
|
|
13
13
|
const { getCodec, allCodecs, ORDER } = require("./codecs");
|
|
14
14
|
const { computePriority, sortByPriority } = require("./priority");
|
|
15
15
|
const { buildInventory } = require("./unmanaged");
|
|
16
|
+
const { buildEmbeddedInventory } = require("./embedded");
|
|
16
17
|
|
|
17
18
|
// CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
|
|
18
19
|
// back to the raw id (with a "—" placeholder name where the row asks for one).
|
|
@@ -935,17 +936,42 @@ function renderRecommendationsForEco(slice, meta) {
|
|
|
935
936
|
return blocks.join("");
|
|
936
937
|
}
|
|
937
938
|
|
|
939
|
+
// Where a dependency comes from in the tree. A transitive dep (no defining
|
|
940
|
+
// manifest) carries `via` — the chain of g:a from a root direct dep down to it —
|
|
941
|
+
// recorded by the transitive resolver. Show that chain so an EOL/CVE finding on a
|
|
942
|
+
// "dep of a dep" is traceable to the direct dependency that pulls it in. A direct
|
|
943
|
+
// dep falls back to its defining manifest(s).
|
|
944
|
+
function renderDepOrigin(dep, srcRoot) {
|
|
945
|
+
if (dep.via && dep.via.length) {
|
|
946
|
+
const chain = dep.via.map(c => `<code>${esc(c)}</code>`).join(" → ");
|
|
947
|
+
const tail = `<code>${esc(`${dep.groupId}:${dep.artifactId}`)}</code>`;
|
|
948
|
+
const alt = (dep.viaPaths && dep.viaPaths.length > 1) ? ` <span class="defined-more">+${dep.viaPaths.length - 1} more path${dep.viaPaths.length > 2 ? "s" : ""}</span>` : "";
|
|
949
|
+
return `<div class="defined-in"><span class="defined-label">pulled in via:</span> ${chain} → ${tail}${alt}</div>`;
|
|
950
|
+
}
|
|
951
|
+
return renderDefinedIn(dep, srcRoot);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// The source of an EOL verdict: the endoflife.date product queried, and which
|
|
955
|
+
// mapping rule (data/eol-mapping.json) matched the dep to that product.
|
|
956
|
+
function renderEolSource(e) {
|
|
957
|
+
const slug = e.productSlug || "";
|
|
958
|
+
const link = slug ? `endoflife.date/${esc(slug)}` : "endoflife.date";
|
|
959
|
+
const rule = e.via ? `<br><span style="color:#6b7280">matched via ${esc(e.via)} = <code>${esc(e.viaKey || "")}</code></span>` : "";
|
|
960
|
+
return `<span>${link}</span>${rule}`;
|
|
961
|
+
}
|
|
962
|
+
|
|
938
963
|
function renderEolTable(eolResults) {
|
|
939
964
|
if (!eolResults?.length) return `<div class="empty">No end-of-life frameworks detected.</div>`;
|
|
940
965
|
const rows = eolResults.map(e => `<tr>
|
|
941
966
|
<td class="dep">${esc(e.product)}</td>
|
|
942
|
-
<td class="dep">${esc(depDisplayName(e.dep))}<br><span style="color:#6b7280">${esc(e.dep.version || "?")}</span>${
|
|
967
|
+
<td class="dep">${esc(depDisplayName(e.dep))}<br><span style="color:#6b7280">${esc(e.dep.version || "?")}</span>${renderDepOrigin(e.dep, RENDER_CTX.srcRoot)}</td>
|
|
943
968
|
<td>${esc(e.eol || "")}</td>
|
|
944
969
|
<td>${esc(e.latest || "")}</td>
|
|
970
|
+
<td class="dep">${renderEolSource(e)}</td>
|
|
945
971
|
<td class="desc">${esc(e.notes || "")}</td>
|
|
946
972
|
</tr>`).join("\n");
|
|
947
973
|
return `<table>
|
|
948
|
-
<thead><tr><th>Product</th><th>Dependency</th><th>EOL date</th><th>Latest</th><th>Notes</th></tr></thead>
|
|
974
|
+
<thead><tr><th>Product</th><th>Dependency</th><th>EOL date</th><th>Latest</th><th>Source</th><th>Notes</th></tr></thead>
|
|
949
975
|
<tbody>${rows}</tbody>
|
|
950
976
|
</table>`;
|
|
951
977
|
}
|
|
@@ -1338,7 +1364,7 @@ const LICENSE_CAT_ORDER = ["network-copyleft", "strong-copyleft", "proprietary",
|
|
|
1338
1364
|
|
|
1339
1365
|
function renderLicenseChapter(licenseResults) {
|
|
1340
1366
|
if (!licenseResults || !licenseResults.assessed?.length) {
|
|
1341
|
-
return `<div class="empty">No license data
|
|
1367
|
+
return `<div class="empty">No license data — the license scan is off by default (enable with <code>--licenses</code>); also empty when offline or registries return none.</div>`;
|
|
1342
1368
|
}
|
|
1343
1369
|
const { byCategory } = licenseResults;
|
|
1344
1370
|
const blocks = LICENSE_CAT_ORDER.filter(cat => byCategory[cat]?.length).map(cat => {
|
|
@@ -1397,7 +1423,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1397
1423
|
const licenseContent = renderLicenseChapter(licenseResults);
|
|
1398
1424
|
const licenseTotal = licenseResults?.assessed?.length || 0;
|
|
1399
1425
|
const licenseFlagged = licenseResults?.flagged?.length || 0;
|
|
1400
|
-
const
|
|
1426
|
+
const embeddedInventory = buildEmbeddedInventory(resolvedDeps || new Map(), embMatchesActive);
|
|
1427
|
+
const embeddedContent = renderEmbeddedChapter(embeddedInventory, embMatchesActive, projectInfo?.src);
|
|
1401
1428
|
const unmanagedInventory = buildInventory(resolvedDeps || new Map());
|
|
1402
1429
|
const unmanagedContent = renderUnmanagedInventory(unmanagedInventory, projectInfo?.src);
|
|
1403
1430
|
const vendoredJsInv = vendoredJsInventory || [];
|
|
@@ -1433,7 +1460,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1433
1460
|
outdatedTotal: outdatedResults?.length || 0,
|
|
1434
1461
|
licenseTotal,
|
|
1435
1462
|
licenseFlagged,
|
|
1436
|
-
embeddedTotal:
|
|
1463
|
+
embeddedTotal: embeddedInventory.length,
|
|
1437
1464
|
unmanagedTotal: unmanagedInventory.length,
|
|
1438
1465
|
vendoredJsTotal: vendoredJsInv.length,
|
|
1439
1466
|
fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
|
|
@@ -1455,7 +1482,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1455
1482
|
|
|
1456
1483
|
${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
|
|
1457
1484
|
${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
|
|
1458
|
-
${
|
|
1485
|
+
${embeddedInventory.length ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embeddedInventory.length})`, embeddedContent, { open: embeddedInventory.length <= 50, id: "ch1e" }) : ""}
|
|
1459
1486
|
${unmanagedInventory.length ? majorSection(`1C. Unmanaged / vendored binaries (${unmanagedInventory.length})`, unmanagedContent, { open: unmanagedInventory.length <= 50, id: "ch1c" }) : ""}
|
|
1460
1487
|
${vendoredJsInv.length ? majorSection(`1D. Unmanaged / vendored JavaScript (${vendoredJsInv.length})`, vendoredJsContent, { open: vendoredJsInv.length <= 50, id: "ch1d" }) : ""}
|
|
1461
1488
|
${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
|
|
@@ -1520,22 +1547,39 @@ function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vend
|
|
|
1520
1547
|
return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
|
|
1521
1548
|
}
|
|
1522
1549
|
|
|
1523
|
-
// Embedded-binary
|
|
1524
|
-
// part of the manifestPath before the first "!/"),
|
|
1525
|
-
//
|
|
1526
|
-
function renderEmbeddedChapter(
|
|
1527
|
-
if (!
|
|
1528
|
-
const
|
|
1550
|
+
// Embedded-binary inventory chapter (1B): every embedded coordinate grouped by its
|
|
1551
|
+
// top-level archive (the part of the manifestPath before the first "!/"), with its
|
|
1552
|
+
// CVE status. Vulnerable coords additionally get the full CVE detail table.
|
|
1553
|
+
function renderEmbeddedChapter(inventory, embeddedMatches, srcRoot) {
|
|
1554
|
+
if (!inventory.length) return `<div class="empty">No embedded JAR/WAR/EAR coordinates found.</div>`;
|
|
1555
|
+
const vulnN = inventory.filter(e => e.vulnCount > 0).length;
|
|
1556
|
+
const intro = `<div class="fp-intro">Maven coordinates discovered inside committed <code>.jar</code>/<code>.war</code>/<code>.ear</code> binaries (vendored libs, fat-jars, shaded uber-jars) — <strong>not declared in any <code>pom.xml</code></strong>. Unmanaged third-party code: unknown provenance and patch story even when not vulnerable. <strong>${vulnN}</strong> of ${inventory.length} carry known CVEs (patch by rebuilding/replacing the containing archive).</div>`;
|
|
1557
|
+
// CVE matches keyed by archive, for the per-archive detail tables.
|
|
1558
|
+
const matchesByArchive = new Map();
|
|
1559
|
+
for (const m of (embeddedMatches || [])) {
|
|
1560
|
+
const top = String((m.dep?.manifestPaths || [])[0] || "(unknown archive)").split("!/")[0];
|
|
1561
|
+
if (!matchesByArchive.has(top)) matchesByArchive.set(top, []);
|
|
1562
|
+
matchesByArchive.get(top).push(m);
|
|
1563
|
+
}
|
|
1529
1564
|
const byArchive = new Map();
|
|
1530
|
-
for (const
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
if (!byArchive.has(top)) byArchive.set(top, []);
|
|
1534
|
-
byArchive.get(top).push(m);
|
|
1565
|
+
for (const e of inventory) {
|
|
1566
|
+
if (!byArchive.has(e.archive)) byArchive.set(e.archive, []);
|
|
1567
|
+
byArchive.get(e.archive).push(e);
|
|
1535
1568
|
}
|
|
1536
|
-
const blocks = [...byArchive.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([archive, list]) =>
|
|
1537
|
-
|
|
1538
|
-
|
|
1569
|
+
const blocks = [...byArchive.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([archive, list]) => {
|
|
1570
|
+
const rows = list.map(e => {
|
|
1571
|
+
const coord = `${e.groupId}:${e.artifactId}:${e.version}`;
|
|
1572
|
+
const status = e.vulnCount > 0
|
|
1573
|
+
? pill(`${e.vulnCount} CVE${e.vulnCount > 1 ? "s" : ""}${e.maxSeverity ? " · " + e.maxSeverity : ""}`, "#b91c1c")
|
|
1574
|
+
: pill("no known CVE", "#15803d");
|
|
1575
|
+
return `<tr><td class="dep"><code>${esc(coord)}</code></td><td>${status}</td></tr>`;
|
|
1576
|
+
}).join("\n");
|
|
1577
|
+
const invTable = `<table><thead><tr><th>Coordinate (groupId:artifactId:version)</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
1578
|
+
const cveDetail = matchesByArchive.has(archive)
|
|
1579
|
+
? `<div class="defined-in" style="margin-top:8px"><span class="defined-label">CVE detail:</span></div>` + renderCveTable(matchesByArchive.get(archive))
|
|
1580
|
+
: "";
|
|
1581
|
+
return minorSection(`📦 <code class="path">${esc(archive)}</code> (${list.length})`, invTable + cveDetail, { open: list.length <= 30 });
|
|
1582
|
+
}).join("\n");
|
|
1539
1583
|
return intro + blocks;
|
|
1540
1584
|
}
|
|
1541
1585
|
|
|
@@ -1984,6 +2028,7 @@ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retire
|
|
|
1984
2028
|
|
|
1985
2029
|
module.exports = {
|
|
1986
2030
|
computeStats,
|
|
2031
|
+
buildEmbeddedInventory,
|
|
1987
2032
|
generateHtmlReport,
|
|
1988
2033
|
generateWordReport,
|
|
1989
2034
|
writeReports,
|
package/lib/embedded.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/embedded.js — inventory of Maven coordinates discovered inside committed
|
|
3
|
+
* .jar/.war/.ear archives (provenance:"embedded"), whether or not they carry a CVE.
|
|
4
|
+
*
|
|
5
|
+
* This is a governance / cyber-hygiene signal: the JAR twin of the native-binary
|
|
6
|
+
* inventory (lib/unmanaged.js → chapter 1C) and the vendored-JS inventory
|
|
7
|
+
* (lib/retire.js → chapter 1D). Code that ships inside a committed binary which no
|
|
8
|
+
* pom.xml declares has unknown provenance and patch story even when not vulnerable.
|
|
9
|
+
*
|
|
10
|
+
* Pure: shared by the HTML report (chapter 1B) and the JSON export so both list the
|
|
11
|
+
* SAME set. CVE counts/severity are cross-referenced from the embedded CVE matches
|
|
12
|
+
* by coordKey.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const EMB_SEV_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOWN: 0 };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param resolvedDeps Map of all resolved deps (embedded ones carry provenance:"embedded").
|
|
19
|
+
* @param embeddedMatches CVE matches whose dep is embedded (subset of the scan's matches).
|
|
20
|
+
* @returns sorted array of { archive, groupId, artifactId, version, coordKey, manifestPath, vulnCount, maxSeverity }
|
|
21
|
+
*/
|
|
22
|
+
function buildEmbeddedInventory(resolvedDeps, embeddedMatches) {
|
|
23
|
+
const cveByCoord = new Map(); // coordKey → { ids:Set, maxSeverity }
|
|
24
|
+
for (const m of (embeddedMatches || [])) {
|
|
25
|
+
const key = m.dep?.coordKey;
|
|
26
|
+
if (!key) continue;
|
|
27
|
+
let e = cveByCoord.get(key);
|
|
28
|
+
if (!e) { e = { ids: new Set(), maxSeverity: null }; cveByCoord.set(key, e); }
|
|
29
|
+
e.ids.add(m.cve?.id);
|
|
30
|
+
const sev = m.cve?.severity || "UNKNOWN";
|
|
31
|
+
if (!e.maxSeverity || (EMB_SEV_RANK[sev] || 0) > (EMB_SEV_RANK[e.maxSeverity] || 0)) e.maxSeverity = sev;
|
|
32
|
+
}
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const dep of (resolvedDeps?.values?.() || [])) {
|
|
35
|
+
if (dep.provenance !== "embedded") continue;
|
|
36
|
+
const manifestPath = (dep.manifestPaths || [])[0] || "(unknown archive)";
|
|
37
|
+
const archive = String(manifestPath).split("!/")[0];
|
|
38
|
+
const cve = cveByCoord.get(dep.coordKey);
|
|
39
|
+
out.push({
|
|
40
|
+
archive,
|
|
41
|
+
groupId: dep.groupId || dep.namespace || "",
|
|
42
|
+
artifactId: dep.artifactId || dep.name || "",
|
|
43
|
+
version: dep.version || "",
|
|
44
|
+
coordKey: dep.coordKey,
|
|
45
|
+
manifestPath,
|
|
46
|
+
vulnCount: cve ? cve.ids.size : 0,
|
|
47
|
+
maxSeverity: cve ? cve.maxSeverity : null,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
out.sort((a, b) =>
|
|
51
|
+
(EMB_SEV_RANK[b.maxSeverity] || 0) - (EMB_SEV_RANK[a.maxSeverity] || 0)
|
|
52
|
+
|| String(a.archive).localeCompare(String(b.archive))
|
|
53
|
+
|| `${a.groupId}:${a.artifactId}:${a.version}`.localeCompare(`${b.groupId}:${b.artifactId}:${b.version}`));
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { buildEmbeddedInventory, EMB_SEV_RANK };
|
package/lib/json-export.js
CHANGED
|
@@ -72,6 +72,10 @@ function buildFindings(payload = {}) {
|
|
|
72
72
|
const { buildInventory } = require("./unmanaged");
|
|
73
73
|
const unmanaged = resolvedDeps ? buildInventory(resolvedDeps) : [];
|
|
74
74
|
|
|
75
|
+
const { buildEmbeddedInventory } = require("./embedded");
|
|
76
|
+
const embeddedMatches = cveMatches.filter(m => m.dep?.provenance === "embedded");
|
|
77
|
+
const embedded = resolvedDeps ? buildEmbeddedInventory(resolvedDeps, embeddedMatches) : [];
|
|
78
|
+
|
|
75
79
|
const sevCounts = Object.fromEntries(SEV.map(s => [s, 0]));
|
|
76
80
|
let kev = 0;
|
|
77
81
|
for (const m of cveMatches) {
|
|
@@ -94,16 +98,18 @@ function buildFindings(payload = {}) {
|
|
|
94
98
|
licensesFlagged: licenseResults?.flagged?.length || 0,
|
|
95
99
|
vendored: retireMatches.length,
|
|
96
100
|
unmanaged: unmanaged.length,
|
|
101
|
+
embedded: embedded.length,
|
|
97
102
|
vendoredJs: vendoredJsInventory.length,
|
|
98
103
|
suppressed: cveMatches.filter(m => m.suppressed).length,
|
|
99
104
|
},
|
|
100
105
|
cve: cveMatches.map(cveFinding),
|
|
101
106
|
vendored: retireMatches.map(cveFinding),
|
|
102
|
-
eol: eolResults.map(e => ({ product: e.product, eol: e.eol, dep: depBrief(e.dep) })),
|
|
107
|
+
eol: eolResults.map(e => ({ product: e.product, productSlug: e.productSlug || null, via: e.via || null, viaKey: e.viaKey || null, eol: e.eol, dep: depBrief(e.dep) })),
|
|
103
108
|
obsolete: obsoleteResults.map(o => ({ reason: o.reason || null, replacement: o.replacement || null, source: o.source || null, dep: depBrief(o.dep) })),
|
|
104
109
|
outdated: outdatedResults.map(o => ({ latest: o.latest, releaseDate: o.releaseDate || null, dep: depBrief(o.dep) })),
|
|
105
110
|
licenses: (licenseResults?.assessed || []).map(e => ({ category: e.category, licenses: e.ids.concat(e.raw), source: e.source || null, dep: depBrief(e.dep) })),
|
|
106
111
|
unmanaged,
|
|
112
|
+
embedded,
|
|
107
113
|
vendoredJs: vendoredJsInventory,
|
|
108
114
|
};
|
|
109
115
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/manifest-copy.js — when writing a cleaned tree (`-t`), copy every NON-Maven
|
|
3
|
+
* ecosystem manifest / lockfile from the source into the target at the same relative
|
|
4
|
+
* path, so `snyk test --all-projects` on the cleaned tree scans every ecosystem — not
|
|
5
|
+
* just the cleaned Maven POMs (which are written separately by core.rewritePoms).
|
|
6
|
+
*
|
|
7
|
+
* Maven is excluded (its cleaned POMs are the whole point of the rewrite); the binary
|
|
8
|
+
* codec is excluded (its files aren't dependency manifests). node_modules / vendor and
|
|
9
|
+
* friends are pruned so we copy the project's own lockfiles, not vendored ones.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const { makeDirFilter } = require("./path-filter");
|
|
14
|
+
const { allCodecs } = require("./codecs");
|
|
15
|
+
|
|
16
|
+
// Ecosystems whose manifests/lockfiles Snyk reads off disk.
|
|
17
|
+
const COPY_ECOSYSTEMS = new Set(["npm", "nuget", "composer", "pypi", "go", "ruby"]);
|
|
18
|
+
// Companion files that aren't a codec's primary manifest but Snyk/the build need.
|
|
19
|
+
const COMPANION_FILES = ["Directory.Packages.props", "Directory.Build.props", "nuget.config", "Gemfile", "Pipfile", "go.work", "go.work.sum"];
|
|
20
|
+
// Directories never worth copying manifests from (installed/vendored trees).
|
|
21
|
+
const SKIP_DIRS = new Set([
|
|
22
|
+
"node_modules", "bower_components", "jspm_packages", "vendor",
|
|
23
|
+
".git", ".svn", ".hg", ".idea", ".vscode", ".gradle", ".mvn",
|
|
24
|
+
"target", "dist", "build", "out", "bin", "obj", ".next", ".nuxt", "coverage",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** Pure: the exact filenames + extensions that count as a copyable manifest. */
|
|
28
|
+
function manifestMatchers() {
|
|
29
|
+
const exact = new Set(COMPANION_FILES);
|
|
30
|
+
const exts = [];
|
|
31
|
+
for (const c of allCodecs()) {
|
|
32
|
+
if (!COPY_ECOSYSTEMS.has(c.id)) continue;
|
|
33
|
+
for (const n of c.manifestNames || []) {
|
|
34
|
+
if (n.startsWith("*.")) exts.push(n.slice(1)); // "*.csproj" → ".csproj"
|
|
35
|
+
else exact.add(n);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { exact, exts };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Pure: is this basename a manifest we copy? */
|
|
42
|
+
function isManifestName(name, matchers = manifestMatchers()) {
|
|
43
|
+
return matchers.exact.has(name) || matchers.exts.some(e => name.endsWith(e));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Copy every matching manifest from srcRoot into targetRoot at the same relative path.
|
|
48
|
+
* @returns { copied: number, files: string[] } (files are relative paths)
|
|
49
|
+
*/
|
|
50
|
+
async function copyEcosystemManifests(srcRoot, targetRoot, opts = {}) {
|
|
51
|
+
const matchers = manifestMatchers();
|
|
52
|
+
const skipDir = makeDirFilter({ srcRoot, defaultSkip: SKIP_DIRS, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
|
|
53
|
+
const files = [];
|
|
54
|
+
const walk = async (dir) => {
|
|
55
|
+
let entries;
|
|
56
|
+
try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
const abs = path.join(dir, e.name);
|
|
59
|
+
if (e.isDirectory()) { if (!skipDir(abs, e.name)) await walk(abs); continue; }
|
|
60
|
+
if (!e.isFile()) continue;
|
|
61
|
+
if (!isManifestName(e.name, matchers)) continue;
|
|
62
|
+
const rel = path.relative(srcRoot, abs);
|
|
63
|
+
const dest = path.join(targetRoot, rel);
|
|
64
|
+
try {
|
|
65
|
+
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
|
66
|
+
await fs.promises.copyFile(abs, dest);
|
|
67
|
+
files.push(rel);
|
|
68
|
+
} catch { /* best effort per file */ }
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
await walk(srcRoot);
|
|
72
|
+
return { copied: files.length, files };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { manifestMatchers, isManifestName, copyEcosystemManifests, COPY_ECOSYSTEMS };
|
package/lib/maven-bom.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/maven-bom.js — resolve EXTERNAL import-scope BOMs (e.g. spring-boot-dependencies)
|
|
3
|
+
* from Maven Central and backfill the versions of declared deps that the BOM manages.
|
|
4
|
+
*
|
|
5
|
+
* core.js follows LOCAL `<scope>import</scope>` BOMs (their POM is in the source tree)
|
|
6
|
+
* but cannot enumerate an external BOM's managed-version table without fetching it. So
|
|
7
|
+
* in a typical Spring Boot project every `spring-boot-starter-*` (declared without a
|
|
8
|
+
* version, version pinned by the imported BOM) ends up unresolved — flooding chapter 0
|
|
9
|
+
* and dropping out of the CVE/EOL/outdated scans.
|
|
10
|
+
*
|
|
11
|
+
* This module fetches each external import BOM via transitive.js#effectivePom (which
|
|
12
|
+
* already merges the parent chain, resolves `${properties}` and recursively expands
|
|
13
|
+
* nested import BOMs), builds a g:a → version map, and fills it into the versionless
|
|
14
|
+
* declared deps. Network + cached (poms-cache, immutable) + offline-aware (the caller
|
|
15
|
+
* skips it offline). Pure except for the injected/real effectivePom fetch.
|
|
16
|
+
*/
|
|
17
|
+
const transitive = require("./transitive");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract the distinct external import-BOM coordinates from the parsed poms' merged
|
|
21
|
+
* dependencyManagement. `version` is resolved against each pom's property map; entries
|
|
22
|
+
* that stay unresolved (`${…}`) or lack a g/a are skipped.
|
|
23
|
+
* @param propsByPom map pomPath → { properties, dependencyManagement } (xml2js-shaped)
|
|
24
|
+
* @returns [{ groupId, artifactId, version }] deduped by g:a:v
|
|
25
|
+
*/
|
|
26
|
+
function collectImportBoms(propsByPom) {
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const pom of Object.keys(propsByPom || {})) {
|
|
30
|
+
const entry = propsByPom[pom];
|
|
31
|
+
if (!entry) continue;
|
|
32
|
+
const props = entry.properties || {};
|
|
33
|
+
for (const d of (entry.dependencyManagement || [])) {
|
|
34
|
+
if (d?.scope?.[0] !== "import") continue;
|
|
35
|
+
const g = transitive.resolveProps(d.groupId?.[0], props);
|
|
36
|
+
const a = transitive.resolveProps(d.artifactId?.[0], props);
|
|
37
|
+
const v = transitive.resolveProps(d.version?.[0], props);
|
|
38
|
+
if (!g || !a || !v || /\$\{/.test(String(v))) continue;
|
|
39
|
+
const k = `${g}:${a}:${v}`;
|
|
40
|
+
if (seen.has(k)) continue;
|
|
41
|
+
seen.add(k);
|
|
42
|
+
out.push({ groupId: g, artifactId: a, version: v });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve each import BOM to its managed-version table and merge them into one map.
|
|
50
|
+
* First BOM to manage a given g:a wins (mirrors Maven's declaration-order precedence).
|
|
51
|
+
*/
|
|
52
|
+
async function resolveBomManagedVersions(boms, opts = {}) {
|
|
53
|
+
const effectivePom = opts.effectivePom || transitive.effectivePom;
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const bom of boms) {
|
|
56
|
+
let eff = null;
|
|
57
|
+
try { eff = await effectivePom(bom.groupId, bom.artifactId, bom.version, opts); }
|
|
58
|
+
catch { eff = null; }
|
|
59
|
+
if (!eff || !eff.depMgmt) continue;
|
|
60
|
+
for (const d of eff.depMgmt) {
|
|
61
|
+
if (!d.groupId || !d.artifactId) continue;
|
|
62
|
+
const k = `${d.groupId}:${d.artifactId}`;
|
|
63
|
+
if (map.has(k)) continue;
|
|
64
|
+
if (d.version && !/\$\{/.test(String(d.version))) map.set(k, String(d.version));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return map;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 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.
|
|
73
|
+
* @returns number of deps filled
|
|
74
|
+
*/
|
|
75
|
+
function backfillVersions(resolvedDeps, mgmtMap) {
|
|
76
|
+
let filled = 0;
|
|
77
|
+
for (const dep of resolvedDeps.values()) {
|
|
78
|
+
if (dep.ecosystem !== "maven") continue;
|
|
79
|
+
if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
|
|
80
|
+
if (dep.version && !/\$\{/.test(String(dep.version))) continue; // already concrete
|
|
81
|
+
const v = mgmtMap.get(`${dep.groupId}:${dep.artifactId}`);
|
|
82
|
+
if (!v) continue;
|
|
83
|
+
dep.version = v;
|
|
84
|
+
if (!Array.isArray(dep.versions)) dep.versions = [];
|
|
85
|
+
if (!dep.versions.includes(v)) dep.versions.push(v);
|
|
86
|
+
filled++;
|
|
87
|
+
}
|
|
88
|
+
return filled;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One-shot: collect external import BOMs from the poms, resolve their managed versions
|
|
93
|
+
* online, and backfill the versionless declared deps.
|
|
94
|
+
* @returns { boms, filled, mgmtSize }
|
|
95
|
+
*/
|
|
96
|
+
async function resolveAndBackfill(propsByPom, resolvedDeps, opts = {}) {
|
|
97
|
+
const boms = collectImportBoms(propsByPom);
|
|
98
|
+
if (!boms.length) return { boms: 0, filled: 0, mgmtSize: 0 };
|
|
99
|
+
const map = await resolveBomManagedVersions(boms, opts);
|
|
100
|
+
const filled = backfillVersions(resolvedDeps, map);
|
|
101
|
+
return { boms: boms.length, filled, mgmtSize: map.size };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { collectImportBoms, resolveBomManagedVersions, backfillVersions, resolveAndBackfill };
|
package/lib/outdated.js
CHANGED
|
@@ -42,6 +42,13 @@ function isEolCacheFresh(maxAge = EOL_CACHE_MAX_AGE_MS) {
|
|
|
42
42
|
|
|
43
43
|
// -------- EOL via endoflife.date --------
|
|
44
44
|
|
|
45
|
+
// Tag the matched mapping entry with HOW it matched (which rule + which key), so
|
|
46
|
+
// the report can show where an EOL verdict comes from. Returns a COPY — the shared
|
|
47
|
+
// EOL_MAPPING objects must never be mutated (they're reused across deps).
|
|
48
|
+
function withOrigin(entry, via, viaKey) {
|
|
49
|
+
return entry ? { ...entry, via, viaKey } : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
function findEolProduct(dep) {
|
|
46
53
|
// npm packages — and WebJars, which are client-side JS shipped as Maven
|
|
47
54
|
// artifacts — resolve by JS library name, not Maven coordinate. npm deps
|
|
@@ -49,36 +56,39 @@ function findEolProduct(dep) {
|
|
|
49
56
|
// first (org.webjars:angularjs → "angularjs", org.webjars.npm:angular__core
|
|
50
57
|
// → "@angular/core"). The npm package literally named "angular" is AngularJS
|
|
51
58
|
// 1.x; modern Angular is the @angular/* scope — hence the name vs. scope maps.
|
|
59
|
+
const isWebjar = dep.ecosystem !== "npm" && webjarToNpm(dep) != null;
|
|
52
60
|
const npmName = dep.ecosystem === "npm" ? (dep.artifactId || "") : webjarToNpm(dep)?.name;
|
|
53
61
|
if (npmName != null) {
|
|
54
62
|
const byName = EOL_MAPPING.by_npm_name?.[npmName];
|
|
55
|
-
if (byName) return byName;
|
|
63
|
+
if (byName) return withOrigin(byName, isWebjar ? "webjar" : "npm-name", npmName);
|
|
56
64
|
const scopes = Object.keys(EOL_MAPPING.by_npm_scope || {})
|
|
57
65
|
.sort((a, b) => b.length - a.length);
|
|
58
66
|
for (const s of scopes) {
|
|
59
|
-
if (npmName.startsWith(s)) return EOL_MAPPING.by_npm_scope[s];
|
|
67
|
+
if (npmName.startsWith(s)) return withOrigin(EOL_MAPPING.by_npm_scope[s], isWebjar ? "webjar" : "npm-scope", s);
|
|
60
68
|
}
|
|
61
69
|
return null;
|
|
62
70
|
}
|
|
63
71
|
if (dep.ecosystem === "composer") {
|
|
64
72
|
const full = `${dep.namespace || dep.groupId || ""}/${dep.name || dep.artifactId}`.toLowerCase();
|
|
65
|
-
return EOL_MAPPING.by_composer_name?.[full]
|
|
73
|
+
return withOrigin(EOL_MAPPING.by_composer_name?.[full], "composer-name", full);
|
|
66
74
|
}
|
|
67
75
|
if (dep.ecosystem === "pypi") {
|
|
68
|
-
|
|
76
|
+
const k = (dep.name || dep.artifactId || "").toLowerCase();
|
|
77
|
+
return withOrigin(EOL_MAPPING.by_pypi_name?.[k], "pypi-name", k);
|
|
69
78
|
}
|
|
70
79
|
if (dep.ecosystem === "nuget") {
|
|
71
|
-
|
|
80
|
+
const k = (dep.name || dep.artifactId || "").toLowerCase();
|
|
81
|
+
return withOrigin(EOL_MAPPING.by_nuget_name?.[k], "nuget-name", k);
|
|
72
82
|
}
|
|
73
83
|
const key = `${dep.groupId}:${dep.artifactId}`;
|
|
74
84
|
const direct = EOL_MAPPING.by_group_artifact?.[key];
|
|
75
|
-
if (direct) return direct;
|
|
85
|
+
if (direct) return withOrigin(direct, "group-artifact", key);
|
|
76
86
|
// Match longest groupId prefix
|
|
77
87
|
const prefixes = Object.keys(EOL_MAPPING.by_group_prefix || {})
|
|
78
88
|
.sort((a, b) => b.length - a.length);
|
|
79
89
|
for (const p of prefixes) {
|
|
80
90
|
if (dep.groupId === p || dep.groupId.startsWith(p + ".")) {
|
|
81
|
-
return EOL_MAPPING.by_group_prefix[p];
|
|
91
|
+
return withOrigin(EOL_MAPPING.by_group_prefix[p], "group-prefix", p);
|
|
82
92
|
}
|
|
83
93
|
}
|
|
84
94
|
return null;
|
|
@@ -154,6 +164,8 @@ async function checkEolDeps(resolvedDeps, opts = {}) {
|
|
|
154
164
|
dep,
|
|
155
165
|
product: product.label || product.product,
|
|
156
166
|
productSlug: product.product,
|
|
167
|
+
via: product.via || null,
|
|
168
|
+
viaKey: product.viaKey || null,
|
|
157
169
|
cycle: cycle.cycle,
|
|
158
170
|
eol: cycle.eol === true ? "true" : String(cycle.eol),
|
|
159
171
|
latest: cycle.latest || null,
|