fad-checker 1.0.5 → 2.0.0
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/CHANGELOG.md +47 -0
- package/CLAUDE.md +28 -9
- package/README.md +27 -13
- package/completions/fad-checker.bash +4 -1
- package/completions/fad-checker.zsh +9 -0
- package/data/eol-mapping.json +17 -0
- package/docs/ARCHITECTURE.md +35 -7
- package/docs/USAGE.md +15 -7
- package/docs/superpowers/plans/2026-05-29-codec-composer.md +556 -0
- package/docs/superpowers/plans/2026-05-29-codec-foundation.md +851 -0
- package/docs/superpowers/plans/2026-05-29-codec-nuget.md +432 -0
- package/docs/superpowers/plans/2026-05-29-codec-pypi.md +450 -0
- package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +251 -0
- package/fad-checker.js +109 -83
- package/lib/codecs/codec.interface.js +27 -0
- package/lib/codecs/composer/parse.js +59 -0
- package/lib/codecs/composer/registry.js +92 -0
- package/lib/codecs/composer.codec.js +93 -0
- package/lib/codecs/index.js +37 -0
- package/lib/codecs/maven.codec.js +71 -0
- package/lib/{npm → codecs/npm}/collect.js +32 -18
- package/lib/codecs/npm.codec.js +52 -0
- package/lib/codecs/nuget/parse.js +75 -0
- package/lib/codecs/nuget/registry.js +88 -0
- package/lib/codecs/nuget.codec.js +92 -0
- package/lib/codecs/pypi/parse.js +71 -0
- package/lib/codecs/pypi/registry.js +89 -0
- package/lib/codecs/pypi.codec.js +81 -0
- package/lib/codecs/recipes.js +108 -0
- package/lib/codecs/select.js +27 -0
- package/lib/codecs/yarn.codec.js +19 -0
- package/lib/cve-match.js +30 -14
- package/lib/cve-report.js +130 -83
- package/lib/dep-record.js +60 -0
- package/lib/osv.js +33 -19
- package/lib/outdated.js +13 -2
- package/package.json +3 -2
- package/test/cli-ecosystem.test.js +30 -0
- package/test/codec-edge-cases.test.js +131 -0
- package/test/codec-integration.test.js +119 -0
- package/test/codecs.test.js +92 -0
- package/test/composer.test.js +71 -0
- package/test/cve-match.test.js +40 -0
- package/test/cve-report.test.js +9 -0
- package/test/dep-record.test.js +31 -0
- package/test/fixtures/csharp-config/packages.config +4 -0
- package/test/fixtures/csharp-csproj/Directory.Packages.props +5 -0
- package/test/fixtures/csharp-csproj/app.csproj +7 -0
- package/test/fixtures/csharp-lock/packages.lock.json +6 -0
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +2 -1
- package/test/fixtures/php-app/composer.json +5 -0
- package/test/fixtures/php-app/composer.lock +10 -0
- package/test/fixtures/polyglot/cs/packages.lock.json +1 -0
- package/test/fixtures/polyglot/js/package-lock.json +1 -0
- package/test/fixtures/polyglot/js/package.json +1 -0
- package/test/fixtures/polyglot/mvn/pom.xml +7 -0
- package/test/fixtures/polyglot/php/composer.lock +1 -0
- package/test/fixtures/polyglot/py/poetry.lock +3 -0
- package/test/fixtures/python-pipenv/Pipfile.lock +1 -0
- package/test/fixtures/python-poetry/poetry.lock +7 -0
- package/test/fixtures/python-reqs/requirements.txt +5 -0
- package/test/fixtures/python-uv/uv.lock +3 -0
- package/test/monorepo.test.js +1 -1
- package/test/npm-registry.test.js +1 -1
- package/test/npm.test.js +8 -4
- package/test/nuget.test.js +66 -0
- package/test/osv.test.js +62 -0
- package/test/pypi.test.js +72 -0
- package/test/webjar.test.js +1 -1
- /package/lib/{npm → codecs/npm}/parse.js +0 -0
- /package/lib/{npm → codecs/npm}/registry.js +0 -0
package/lib/cve-report.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
const fs = require("fs");
|
|
9
9
|
const path = require("path");
|
|
10
|
+
const { getCodec, allCodecs, ORDER } = require("./codecs");
|
|
10
11
|
|
|
11
12
|
// CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
|
|
12
13
|
// back to the raw id (with a "—" placeholder name where the row asks for one).
|
|
@@ -132,6 +133,7 @@ code.path { font-family: ui-monospace, monospace; font-size: 0.9em; background:
|
|
|
132
133
|
.exec-summary .exec-head { display: flex; align-items: center; gap: 12px; }
|
|
133
134
|
.exec-summary .exec-level { font-size: 12px; font-weight: 700; letter-spacing: 1px; padding: 4px 12px; border-radius: 999px; }
|
|
134
135
|
.exec-summary .exec-title { font-size: 18px; font-weight: 600; color: #111827; }
|
|
136
|
+
.exec-summary .exec-copy-btn { margin-left: auto; }
|
|
135
137
|
.exec-summary .exec-meta { font-size: 12px; color: #6b7280; }
|
|
136
138
|
.exec-summary .exec-bullets { margin: 4px 0 0; padding-left: 22px; font-size: 13px; color: #374151; line-height: 1.6; }
|
|
137
139
|
.exec-summary.exec-critical { border-color: #dc2626; }
|
|
@@ -347,14 +349,24 @@ function makeRelative(absPath, srcRoot) {
|
|
|
347
349
|
} catch { return absPath; }
|
|
348
350
|
}
|
|
349
351
|
|
|
352
|
+
// Présentation des coordonnées, déléguée au codec. Règle générique : le codec
|
|
353
|
+
// fournit la coord canonique (maven "g:a", npm "name", composer "vendor/pkg"…) ;
|
|
354
|
+
// Maven la porte déjà son namespace, les autres reçoivent un tag d'écosystème
|
|
355
|
+
// ("npm:", "composer:", …) pour lever l'ambiguïté dans un report multi-eco.
|
|
356
|
+
function codecFor(dep) { return getCodec(dep?.ecosystemType) || getCodec(dep?.ecosystem); }
|
|
357
|
+
|
|
350
358
|
function depDisplayParts(dep) {
|
|
351
|
-
|
|
352
|
-
return { groupLine: `${dep?.groupId ?? ""}:`, nameLine: dep?.artifactId || "" };
|
|
359
|
+
const c = codecFor(dep);
|
|
360
|
+
if (!c) return { groupLine: `${dep?.groupId ?? ""}:`, nameLine: dep?.artifactId || "" };
|
|
361
|
+
if (dep.ecosystem === "maven") return { groupLine: `${dep.namespace || dep.groupId || ""}:`, nameLine: dep.name || dep.artifactId || "" };
|
|
362
|
+
return { groupLine: `${dep.ecosystem}:`, nameLine: c.formatCoord(dep) };
|
|
353
363
|
}
|
|
354
364
|
|
|
355
365
|
function depDisplayName(dep) {
|
|
356
|
-
|
|
357
|
-
return `${dep?.groupId ?? ""}:${dep?.artifactId || ""}`;
|
|
366
|
+
const c = codecFor(dep);
|
|
367
|
+
if (!c) return `${dep?.groupId ?? ""}:${dep?.artifactId || ""}`;
|
|
368
|
+
const coord = c.formatCoord(dep);
|
|
369
|
+
return dep.ecosystem === "maven" ? coord : `${dep.ecosystem}:${coord}`;
|
|
358
370
|
}
|
|
359
371
|
|
|
360
372
|
const REF_LABELS = {
|
|
@@ -522,7 +534,7 @@ function renderDetailPanel(m) {
|
|
|
522
534
|
|
|
523
535
|
// 7. Declared-in POMs
|
|
524
536
|
if (dep.pomPaths?.length) {
|
|
525
|
-
const items = dep.pomPaths.slice(0, 10).map(p => `<div>${esc(p)}</div>`).join("");
|
|
537
|
+
const items = dep.pomPaths.slice(0, 10).map(p => `<div>${esc(makeRelative(p, RENDER_CTX.srcRoot))}</div>`).join("");
|
|
526
538
|
sections.push(section("Declared in",
|
|
527
539
|
`<div class="via-list">${items}${dep.pomPaths.length > 10 ? `<div style="color:#9ca3af">+${dep.pomPaths.length - 10} more</div>` : ""}</div>`,
|
|
528
540
|
{ count: `${dep.pomPaths.length} POM${dep.pomPaths.length > 1 ? "s" : ""}`, open: false }));
|
|
@@ -625,65 +637,9 @@ function renderCveTable(cveMatches, opts = {}) {
|
|
|
625
637
|
</table>`;
|
|
626
638
|
}
|
|
627
639
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
*/
|
|
632
|
-
function dependencyManagementSnippet(items) {
|
|
633
|
-
const inner = items.map(it => ` <dependency>
|
|
634
|
-
<groupId>${esc(it.groupId)}</groupId>
|
|
635
|
-
<artifactId>${esc(it.artifactId)}</artifactId>
|
|
636
|
-
<version>${esc(it.fixVersion)}</version>
|
|
637
|
-
</dependency>`).join("\n");
|
|
638
|
-
return `<dependencyManagement>
|
|
639
|
-
<dependencies>
|
|
640
|
-
${inner}
|
|
641
|
-
</dependencies>
|
|
642
|
-
</dependencyManagement>`;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
function npmOverridesSnippet(items) {
|
|
646
|
-
const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
|
|
647
|
-
return `{
|
|
648
|
-
"overrides": {
|
|
649
|
-
${lines}
|
|
650
|
-
}
|
|
651
|
-
}`;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function yarnResolutionsSnippet(items) {
|
|
655
|
-
const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
|
|
656
|
-
return `{
|
|
657
|
-
"resolutions": {
|
|
658
|
-
${lines}
|
|
659
|
-
}
|
|
660
|
-
}`;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// Per-ecosystem fix-recipe metadata. Keys correspond to ecosystemType values.
|
|
664
|
-
const ECO_RECIPE = {
|
|
665
|
-
maven: {
|
|
666
|
-
label: "Maven",
|
|
667
|
-
pinSection: "A. Pin vulnerable transitives in <dependencyManagement>",
|
|
668
|
-
pinIntro: cnt => `Paste into the root POM to immediately neutralise ${cnt} transitive vulnerabilit${cnt > 1 ? "ies" : "y"}:`,
|
|
669
|
-
snippet: dependencyManagementSnippet,
|
|
670
|
-
directSection: "B. Or update the direct dependencies pulling them in",
|
|
671
|
-
},
|
|
672
|
-
npm: {
|
|
673
|
-
label: "npm",
|
|
674
|
-
pinSection: "A. Pin vulnerable transitives via npm overrides",
|
|
675
|
-
pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>npm install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
|
|
676
|
-
snippet: npmOverridesSnippet,
|
|
677
|
-
directSection: "B. Or update the direct dependencies (and run npm install)",
|
|
678
|
-
},
|
|
679
|
-
yarn: {
|
|
680
|
-
label: "Yarn",
|
|
681
|
-
pinSection: "A. Pin vulnerable transitives via yarn resolutions",
|
|
682
|
-
pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>yarn install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
|
|
683
|
-
snippet: yarnResolutionsSnippet,
|
|
684
|
-
directSection: "B. Or update the direct dependencies (and run yarn install)",
|
|
685
|
-
},
|
|
686
|
-
};
|
|
640
|
+
// Per-ecosystem fix-recipe metadata, now owned by the codecs (lib/codecs/recipes.js).
|
|
641
|
+
// Keys correspond to ecosystemType values.
|
|
642
|
+
const ECO_RECIPE = require("./codecs/recipes");
|
|
687
643
|
|
|
688
644
|
/**
|
|
689
645
|
* From CVE matches (split direct/transitive) + outdatedResults + resolvedDeps,
|
|
@@ -789,7 +745,9 @@ function renderFixRecommendations(recos, allMatches = [], outdatedResults = [])
|
|
|
789
745
|
}
|
|
790
746
|
|
|
791
747
|
// Group by ecosystemType so each tool gets its own pin/update advice.
|
|
792
|
-
|
|
748
|
+
// Buckets + order come from the codec registry (new ecosystems slot in).
|
|
749
|
+
const byEco = {};
|
|
750
|
+
for (const id of ORDER) byEco[id] = { trans: [], directs: [] };
|
|
793
751
|
for (const t of recos.transitivesList) {
|
|
794
752
|
const et = ecoTypeOf(t);
|
|
795
753
|
(byEco[et] || byEco.maven).trans.push(t);
|
|
@@ -799,7 +757,7 @@ function renderFixRecommendations(recos, allMatches = [], outdatedResults = [])
|
|
|
799
757
|
(byEco[et] || byEco.maven).directs.push(d);
|
|
800
758
|
}
|
|
801
759
|
|
|
802
|
-
const ecoOrder =
|
|
760
|
+
const ecoOrder = ORDER;
|
|
803
761
|
const out = [directsSection];
|
|
804
762
|
let letterIdx = 0;
|
|
805
763
|
for (const eco of ecoOrder) {
|
|
@@ -1029,7 +987,7 @@ function summaryCards(stats, eol, obs, out, extra = {}) {
|
|
|
1029
987
|
function mergeMatches(matches) {
|
|
1030
988
|
const byKey = new Map();
|
|
1031
989
|
for (const m of matches) {
|
|
1032
|
-
const key = `${m.dep.groupId}:${m.dep.artifactId}|${m.cve.id}`;
|
|
990
|
+
const key = `${m.dep.groupId}:${m.dep.artifactId}:${m.dep.version}|${m.cve.id}`;
|
|
1033
991
|
if (!byKey.has(key)) {
|
|
1034
992
|
// Deep-ish copy so we can mutate dep.viaPaths
|
|
1035
993
|
byKey.set(key, { ...m, dep: { ...m.dep, viaPaths: m.dep.viaPaths ? [...m.dep.viaPaths] : (m.dep.via ? [m.dep.via] : []) } });
|
|
@@ -1150,7 +1108,7 @@ function renderRetireTable(matches) {
|
|
|
1150
1108
|
const rows = sorted.map(m => `<tr>
|
|
1151
1109
|
<td>${badge((m.cve.severity || "MEDIUM").toUpperCase())}</td>
|
|
1152
1110
|
<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>
|
|
1111
|
+
<td class="dep"><code class="path">${esc(makeRelative(m.dep.vendoredFile || "", RENDER_CTX.srcRoot))}</code></td>
|
|
1154
1112
|
<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
1113
|
<td class="dep">${esc(m.cve.fixVersion || "—")}</td>
|
|
1156
1114
|
<td class="desc">${esc(shortDesc(m.cve.description))}</td>
|
|
@@ -1161,7 +1119,22 @@ function renderRetireTable(matches) {
|
|
|
1161
1119
|
</table>`;
|
|
1162
1120
|
}
|
|
1163
1121
|
|
|
1164
|
-
|
|
1122
|
+
/**
|
|
1123
|
+
* One "vulnerable to" clause for a match, in the format requested for the
|
|
1124
|
+
* Word-pasteable summary: prefer the primary CWE ("CWE-79 : Cross-site
|
|
1125
|
+
* Scripting"), fall back to the CVE id when no weakness is recorded.
|
|
1126
|
+
*/
|
|
1127
|
+
function execWeakness(m) {
|
|
1128
|
+
const cwes = Array.isArray(m.cve.cwes) ? m.cve.cwes : [];
|
|
1129
|
+
const primary = cwes.find(c => cweName(c)) || cwes[0];
|
|
1130
|
+
if (primary) {
|
|
1131
|
+
const label = cweName(primary);
|
|
1132
|
+
return label ? `${primary} : ${label}` : primary;
|
|
1133
|
+
}
|
|
1134
|
+
return m.cve.id || "an unspecified weakness";
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats, eolResults, obsoleteResults, outdatedResults, warnings, totalDeps, topCriticalMatches, interactive = false }) {
|
|
1165
1138
|
const eolN = eolResults?.length || 0;
|
|
1166
1139
|
const obsN = obsoleteResults?.length || 0;
|
|
1167
1140
|
const outN = outdatedResults?.length || 0;
|
|
@@ -1204,10 +1177,59 @@ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats,
|
|
|
1204
1177
|
}).join("")}</ul>
|
|
1205
1178
|
</div>` : "";
|
|
1206
1179
|
|
|
1180
|
+
// ---- Word-pasteable copy payload (built only for the interactive HTML) ----
|
|
1181
|
+
let copyUi = "";
|
|
1182
|
+
if (interactive) {
|
|
1183
|
+
const stripTags = s => s.replace(/<[^>]+>/g, "");
|
|
1184
|
+
const plainBullets = bullets.map(stripTags);
|
|
1185
|
+
const top = topCriticalMatches || [];
|
|
1186
|
+
const topSentences = top.map(m => ({
|
|
1187
|
+
lib: depDisplayName(m.dep),
|
|
1188
|
+
ver: m.dep.version || "?",
|
|
1189
|
+
weakness: execWeakness(m),
|
|
1190
|
+
crit: (m.cve.severity || "HIGH").toUpperCase(),
|
|
1191
|
+
}));
|
|
1192
|
+
|
|
1193
|
+
// Plain-text version (text/plain) — exact bullet format requested.
|
|
1194
|
+
const plainLines = [];
|
|
1195
|
+
plainLines.push(`EXECUTIVE SUMMARY — ${level}`);
|
|
1196
|
+
plainLines.push(`${totalDeps} dependencies scanned`);
|
|
1197
|
+
plainLines.push("");
|
|
1198
|
+
if (plainBullets.length) for (const b of plainBullets) plainLines.push(`- ${b}`);
|
|
1199
|
+
else plainLines.push("- No findings detected.");
|
|
1200
|
+
if (topSentences.length) {
|
|
1201
|
+
plainLines.push("");
|
|
1202
|
+
plainLines.push(`Top ${topSentences.length} most critical:`);
|
|
1203
|
+
for (const t of topSentences) {
|
|
1204
|
+
plainLines.push(`- The library "${t.lib}" version "${t.ver}" is vulnerable to "${t.weakness}" ( ${t.crit} )`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
const plainText = plainLines.join("\n");
|
|
1208
|
+
|
|
1209
|
+
// Rich version (text/html) — inline styles so Word keeps the formatting.
|
|
1210
|
+
const richBullets = plainBullets.length
|
|
1211
|
+
? `<ul style="margin:4px 0 0; padding-left:22px;">${plainBullets.map(b => `<li style="margin:2px 0;">${esc(b)}</li>`).join("")}</ul>`
|
|
1212
|
+
: `<p style="margin:4px 0;">No findings detected.</p>`;
|
|
1213
|
+
const richTop = topSentences.length
|
|
1214
|
+
? `<p style="font-weight:bold; margin:12px 0 4px;">Top ${topSentences.length} most critical:</p>
|
|
1215
|
+
<ul style="margin:0; padding-left:22px;">${topSentences.map(t =>
|
|
1216
|
+
`<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>`
|
|
1217
|
+
: "";
|
|
1218
|
+
const richHtml = `<div style="font-family:Calibri,Arial,sans-serif; font-size:11pt; color:#1f2937;">`
|
|
1219
|
+
+ `<p style="font-size:14pt; font-weight:bold; margin:0 0 2px;">Executive Summary — ${esc(level)}</p>`
|
|
1220
|
+
+ `<p style="margin:0 0 8px; color:#4b5563;">${esc(totalDeps)} dependencies scanned</p>`
|
|
1221
|
+
+ richBullets + richTop + `</div>`;
|
|
1222
|
+
|
|
1223
|
+
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>`
|
|
1224
|
+
+ `<template class="exec-copy-rich">${richHtml}</template>`
|
|
1225
|
+
+ `<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>`;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1207
1228
|
return `<div class="exec-summary exec-${esc(level.toLowerCase())}">
|
|
1208
1229
|
<div class="exec-head">
|
|
1209
1230
|
<span class="exec-level">${esc(level)}</span>
|
|
1210
1231
|
<span class="exec-title">Executive Summary</span>
|
|
1232
|
+
${copyUi}
|
|
1211
1233
|
</div>
|
|
1212
1234
|
<div class="exec-meta">${esc(totalDeps)} dependencies scanned</div>
|
|
1213
1235
|
<ul class="exec-bullets">
|
|
@@ -1248,7 +1270,7 @@ function renderWarningItems(items, itemLimit) {
|
|
|
1248
1270
|
const id = it.id || "";
|
|
1249
1271
|
const paths = (it.manifestPaths || []).slice(0, 6);
|
|
1250
1272
|
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>`
|
|
1273
|
+
? `<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
1274
|
: "";
|
|
1253
1275
|
return `<li><code>${esc(id)}</code>${pathsHtml}</li>`;
|
|
1254
1276
|
}).join("");
|
|
@@ -1298,6 +1320,7 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
|
|
|
1298
1320
|
eolResults, obsoleteResults, outdatedResults,
|
|
1299
1321
|
warnings, totalDeps: resolvedDeps?.size || 0,
|
|
1300
1322
|
topCriticalMatches: pickTopCriticalMatches(prodMatchesActive, retireMatches, 5),
|
|
1323
|
+
interactive: wrapTables, // copy button + scripts only in the HTML output
|
|
1301
1324
|
});
|
|
1302
1325
|
|
|
1303
1326
|
// (C) Table of contents — rendered as a sticky nav. Only enumerates the
|
|
@@ -1364,7 +1387,7 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
|
|
|
1364
1387
|
const out = [];
|
|
1365
1388
|
for (const m of all) {
|
|
1366
1389
|
const dep = m.dep;
|
|
1367
|
-
const k = `${dep.ecosystem === "npm" ? "npm:" : dep.groupId + ":"}${dep.artifactId}`;
|
|
1390
|
+
const k = dep.coordKey || `${dep.ecosystem === "npm" ? "npm:" : dep.groupId + ":"}${dep.artifactId}`;
|
|
1368
1391
|
if (seen.has(k)) continue;
|
|
1369
1392
|
seen.add(k);
|
|
1370
1393
|
out.push(m);
|
|
@@ -1393,12 +1416,17 @@ function renderFalsePositives(matches) {
|
|
|
1393
1416
|
return intro + renderCveTable(matches);
|
|
1394
1417
|
}
|
|
1395
1418
|
|
|
1396
|
-
|
|
1397
|
-
|
|
1419
|
+
// Derived from the codec registry. Overrides keep the exact legacy wording where
|
|
1420
|
+
// a codec's plain label/lockfile isn't what the report historically showed.
|
|
1421
|
+
const ECO_LABEL_OVERRIDE = { npm: "npm (package-lock)" };
|
|
1422
|
+
const ECO_MANIFEST_OVERRIDE = { maven: "pom.xml", npm: "package-lock.json", yarn: "yarn.lock" };
|
|
1423
|
+
const ECO_LABELS = Object.fromEntries(allCodecs().map(c => [c.id, ECO_LABEL_OVERRIDE[c.id] || c.label]));
|
|
1424
|
+
const ECO_MANIFEST_KIND = Object.fromEntries(allCodecs().map(c => [c.id, ECO_MANIFEST_OVERRIDE[c.id] || c.manifestNames[c.manifestNames.length - 1]]));
|
|
1398
1425
|
|
|
1399
1426
|
function renderCveBySectionByEco(matches, chapterPrefix, srcRoot) {
|
|
1400
|
-
const ecoOrder =
|
|
1401
|
-
const byEco = {
|
|
1427
|
+
const ecoOrder = ORDER;
|
|
1428
|
+
const byEco = {};
|
|
1429
|
+
for (const id of ORDER) byEco[id] = [];
|
|
1402
1430
|
for (const m of matches) {
|
|
1403
1431
|
const et = m.dep?.ecosystemType || (m.dep?.ecosystem === "npm" ? "npm" : "maven");
|
|
1404
1432
|
(byEco[et] || byEco.maven).push(m);
|
|
@@ -1488,31 +1516,32 @@ const COPY_SCRIPT = `
|
|
|
1488
1516
|
btn.classList.remove(cls);
|
|
1489
1517
|
}, 1500);
|
|
1490
1518
|
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1519
|
+
// Write rich HTML (+ plain-text fallback) to the clipboard. Shared by the
|
|
1520
|
+
// table buttons and the executive-summary button. richInner is the HTML
|
|
1521
|
+
// fragment; we wrap it with the Office namespaces so Word keeps formatting.
|
|
1522
|
+
async function writeClipboard(richInner, plain, btn){
|
|
1523
|
+
var html = wordWrapper(richInner);
|
|
1495
1524
|
// Modern path: ClipboardItem with text/html + text/plain
|
|
1496
1525
|
try {
|
|
1497
1526
|
if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
|
|
1498
1527
|
await navigator.clipboard.write([
|
|
1499
1528
|
new ClipboardItem({
|
|
1500
|
-
'text/html': new Blob([html],
|
|
1501
|
-
'text/plain': new Blob([
|
|
1529
|
+
'text/html': new Blob([html], { type: 'text/html' }),
|
|
1530
|
+
'text/plain': new Blob([plain], { type: 'text/plain' })
|
|
1502
1531
|
})
|
|
1503
1532
|
]);
|
|
1504
1533
|
flash(btn, 'flashing-ok', '✓ Copied!');
|
|
1505
1534
|
return;
|
|
1506
1535
|
}
|
|
1507
1536
|
} catch(e) { /* fall through to legacy path */ }
|
|
1508
|
-
// Legacy path: select clone, execCommand('copy')
|
|
1537
|
+
// Legacy path: select a contenteditable clone, execCommand('copy')
|
|
1509
1538
|
try {
|
|
1510
1539
|
var temp = document.createElement('div');
|
|
1511
1540
|
temp.setAttribute('contenteditable', 'true');
|
|
1512
1541
|
temp.style.position = 'fixed';
|
|
1513
1542
|
temp.style.left = '-9999px';
|
|
1514
1543
|
temp.style.top = '0';
|
|
1515
|
-
temp.innerHTML =
|
|
1544
|
+
temp.innerHTML = richInner;
|
|
1516
1545
|
document.body.appendChild(temp);
|
|
1517
1546
|
var range = document.createRange();
|
|
1518
1547
|
range.selectNodeContents(temp);
|
|
@@ -1527,7 +1556,14 @@ const COPY_SCRIPT = `
|
|
|
1527
1556
|
flash(btn, 'flashing-err', '✗ Copy failed');
|
|
1528
1557
|
}
|
|
1529
1558
|
}
|
|
1559
|
+
function copyTable(table, btn){
|
|
1560
|
+
var clone = inlineStylesForWord(table);
|
|
1561
|
+
return writeClipboard(clone.outerHTML, tableToTsv(table), btn);
|
|
1562
|
+
}
|
|
1530
1563
|
document.querySelectorAll('.btn-copy').forEach(function(btn){
|
|
1564
|
+
// The executive-summary button is wired separately (it copies a prose
|
|
1565
|
+
// summary, not a table).
|
|
1566
|
+
if (btn.classList.contains('exec-copy-btn')) return;
|
|
1531
1567
|
btn.addEventListener('click', function(e){
|
|
1532
1568
|
e.stopPropagation();
|
|
1533
1569
|
var wrap = btn.closest('.table-wrap');
|
|
@@ -1535,6 +1571,15 @@ const COPY_SCRIPT = `
|
|
|
1535
1571
|
if (table) copyTable(table, btn);
|
|
1536
1572
|
});
|
|
1537
1573
|
});
|
|
1574
|
+
var execBtn = document.querySelector('.exec-copy-btn');
|
|
1575
|
+
if (execBtn) execBtn.addEventListener('click', function(e){
|
|
1576
|
+
e.stopPropagation();
|
|
1577
|
+
var tpl = document.querySelector('.exec-copy-rich');
|
|
1578
|
+
var ta = document.querySelector('.exec-copy-plain');
|
|
1579
|
+
var rich = tpl ? (tpl.innerHTML || '') : '';
|
|
1580
|
+
var plain = ta ? (ta.value || '') : '';
|
|
1581
|
+
writeClipboard(rich, plain, execBtn);
|
|
1582
|
+
});
|
|
1538
1583
|
})();
|
|
1539
1584
|
</script>`;
|
|
1540
1585
|
|
|
@@ -1760,5 +1805,7 @@ module.exports = {
|
|
|
1760
1805
|
generateHtmlReport,
|
|
1761
1806
|
generateWordReport,
|
|
1762
1807
|
writeReports,
|
|
1808
|
+
depDisplayName,
|
|
1809
|
+
depDisplayParts,
|
|
1763
1810
|
esc,
|
|
1764
1811
|
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/dep-record.js — contrat de données unifié partagé par tous les codecs.
|
|
3
|
+
*
|
|
4
|
+
* `coordKey` est la clé de la Map résolue ; elle ne collisionne jamais entre
|
|
5
|
+
* écosystèmes grâce au préfixe `ecosystem:`. `groupId`/`artifactId`/`pomPaths`
|
|
6
|
+
* sont conservés comme alias rétro-compat le temps de migrer tous les
|
|
7
|
+
* consommateurs vers `namespace`/`name`/`manifestPaths`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// NuGet, Composer et PyPI sont case-insensitive : on normalise la clé en lower
|
|
11
|
+
// (l'affichage garde la casse d'origine via dep.name).
|
|
12
|
+
function normalizeForKey(ecosystem, s) {
|
|
13
|
+
if (ecosystem === "nuget" || ecosystem === "composer" || ecosystem === "pypi") return String(s).toLowerCase();
|
|
14
|
+
return s;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// La clé de la Map résolue.
|
|
18
|
+
// - Maven : clé BRUTE "g:a" (pas de préfixe "maven:"). Collision-free face aux
|
|
19
|
+
// préfixes des autres écosystèmes (un groupId reverse-DNS ne commence jamais
|
|
20
|
+
// par "npm:"/"nuget:"/…), et ça garde transitive.js + les tests inchangés.
|
|
21
|
+
// - npm : "npm:<name>" où name peut inclure le scope (@org/pkg) — namespace reste "".
|
|
22
|
+
// - composer : "composer:vendor/pkg". pypi/nuget : "<eco>:<name>".
|
|
23
|
+
function coordKeyFor(ecosystem, namespace, name) {
|
|
24
|
+
if (ecosystem === "maven") {
|
|
25
|
+
const g = namespace || "";
|
|
26
|
+
return g ? `${g}:${name}` : name;
|
|
27
|
+
}
|
|
28
|
+
const ns = normalizeForKey(ecosystem, namespace || "");
|
|
29
|
+
const nm = normalizeForKey(ecosystem, name || "");
|
|
30
|
+
const joined = ns ? `${ns}/${nm}` : nm;
|
|
31
|
+
return `${ecosystem}:${joined}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeDepRecord(input) {
|
|
35
|
+
const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType } = input;
|
|
36
|
+
const concrete = version && !/\$\{/.test(version) ? version : null;
|
|
37
|
+
const manifestPaths = manifestPath ? [manifestPath] : [];
|
|
38
|
+
return {
|
|
39
|
+
ecosystem,
|
|
40
|
+
ecosystemType: ecosystemType || ecosystem,
|
|
41
|
+
namespace: namespace || "",
|
|
42
|
+
name,
|
|
43
|
+
version: version || null,
|
|
44
|
+
versions: concrete ? [concrete] : [],
|
|
45
|
+
coordKey: coordKeyFor(ecosystem, namespace, name),
|
|
46
|
+
scope,
|
|
47
|
+
isDev: !!isDev,
|
|
48
|
+
manifestPaths,
|
|
49
|
+
// Alias rétro-compat : CHAMPS DUPLIQUÉS RÉELS (pas des getters — les depRecords
|
|
50
|
+
// sont spreadés dans des chemins chauds : cve-match.js:175, scan-completeness.js,
|
|
51
|
+
// cve-report.js:1036, snyk.js:105 — un getter serait perdu au spread).
|
|
52
|
+
// groupId/artifactId sont des strings jamais réassignées → pas de dérive.
|
|
53
|
+
// pomPaths PARTAGE la référence de manifestPaths → les push restent synchrones.
|
|
54
|
+
groupId: namespace || "",
|
|
55
|
+
artifactId: name,
|
|
56
|
+
pomPaths: manifestPaths,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { makeDepRecord, coordKeyFor };
|
package/lib/osv.js
CHANGED
|
@@ -96,8 +96,18 @@ function pickPrimaryId(vuln) {
|
|
|
96
96
|
return cveAlias || vuln.id;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
// OSV ecosystem name per codec id. OSV.dev natively supports all of these.
|
|
100
|
+
const OSV_ECO = { maven: "Maven", npm: "npm", yarn: "npm", nuget: "NuGet", composer: "Packagist", pypi: "PyPI" };
|
|
101
|
+
|
|
102
|
+
/** OSV ecosystem string for a dep, derived from its codec id. */
|
|
103
|
+
function osvEcosystemFor(dep) { return OSV_ECO[dep.ecosystem] || "Maven"; }
|
|
104
|
+
|
|
105
|
+
/** Build the OSV package-name key for a dep — delegated to the dep's codec. */
|
|
100
106
|
function osvPkgName(dep) {
|
|
107
|
+
const { getCodec } = require("./codecs");
|
|
108
|
+
const c = getCodec(dep.ecosystem);
|
|
109
|
+
if (c) return c.osvPackageName(dep);
|
|
110
|
+
// Fallback historique si le codec est introuvable.
|
|
101
111
|
return dep.ecosystem === "npm" ? dep.artifactId : `${dep.groupId}:${dep.artifactId}`;
|
|
102
112
|
}
|
|
103
113
|
|
|
@@ -155,11 +165,10 @@ async function queryBatch(deps, opts = {}) {
|
|
|
155
165
|
indexMap[i] = { cached: hit };
|
|
156
166
|
} else {
|
|
157
167
|
indexMap[i] = { queryIdx: queries.length, cacheKey: ck };
|
|
158
|
-
// Ecosystem
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
const pkgName = ecosystem === "npm" ? d.artifactId : `${d.groupId}:${d.artifactId}`;
|
|
168
|
+
// Ecosystem + package name are both derived from the dep's codec, so
|
|
169
|
+
// adding a new ecosystem (NuGet/Packagist/PyPI) needs no change here.
|
|
170
|
+
const ecosystem = osvEcosystemFor(d);
|
|
171
|
+
const pkgName = osvPkgName(d);
|
|
163
172
|
queries.push({
|
|
164
173
|
package: { name: pkgName, ecosystem },
|
|
165
174
|
version: d.version,
|
|
@@ -295,20 +304,23 @@ function runMatches(deps, indexMap, detailById) {
|
|
|
295
304
|
async function queryOsvForDeps(resolvedDeps, opts = {}) {
|
|
296
305
|
const deps = [];
|
|
297
306
|
for (const d of resolvedDeps.values()) {
|
|
298
|
-
if (!d.version || /\$\{|SNAPSHOT/i.test(d.version)) continue;
|
|
299
307
|
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
|
-
|
|
308
|
+
// Query every distinct concrete version (e.g. two profiles pinning the
|
|
309
|
+
// same g:a), not just the representative highest — otherwise a vuln that
|
|
310
|
+
// only affects a lower-versioned variant would be missed.
|
|
311
|
+
const versions = (d.versions && d.versions.length) ? d.versions : [d.version];
|
|
312
|
+
for (const ver of versions) {
|
|
313
|
+
if (!ver || /\$\{|SNAPSHOT/i.test(ver)) continue;
|
|
314
|
+
// npm version specifiers like "^1.0.0" can't be queried against OSV —
|
|
315
|
+
// need a concrete version. Lockfiles always provide one; package.json-
|
|
316
|
+
// only deps are skipped here.
|
|
317
|
+
if (d.ecosystem === "npm" && /^[\^~>=<*]|^(latest|next|workspace:|git\+|file:|link:)/.test(ver)) continue;
|
|
318
|
+
// Spread the whole depRecord (carries namespace/name/coordKey, which the
|
|
319
|
+
// codec's osvPackageName + the report's dedup rely on) and override only
|
|
320
|
+
// the version for this per-version query. Cherry-picking fields here used
|
|
321
|
+
// to drop name/namespace → OSV queried `undefined` for composer/pypi/nuget.
|
|
322
|
+
deps.push({ ...d, version: ver });
|
|
323
|
+
}
|
|
312
324
|
}
|
|
313
325
|
return queryBatch(deps, opts);
|
|
314
326
|
}
|
|
@@ -319,5 +331,7 @@ module.exports = {
|
|
|
319
331
|
vulnToMatch,
|
|
320
332
|
severityFromOsv,
|
|
321
333
|
fixVersionFromOsv,
|
|
334
|
+
osvEcosystemFor,
|
|
335
|
+
osvPkgName,
|
|
322
336
|
OSV_CACHE_DIR,
|
|
323
337
|
};
|
package/lib/outdated.js
CHANGED
|
@@ -9,7 +9,7 @@ const fs = require("fs");
|
|
|
9
9
|
const path = require("path");
|
|
10
10
|
const os = require("os");
|
|
11
11
|
const { compareMavenVersions } = require("./maven-version");
|
|
12
|
-
const { webjarToNpm } = require("./npm/collect");
|
|
12
|
+
const { webjarToNpm } = require("./codecs/npm/collect");
|
|
13
13
|
|
|
14
14
|
const KNOWN_OBSOLETE = require("../data/known-obsolete.json");
|
|
15
15
|
const EOL_MAPPING = require("../data/eol-mapping.json");
|
|
@@ -57,6 +57,16 @@ function findEolProduct(dep) {
|
|
|
57
57
|
}
|
|
58
58
|
return null;
|
|
59
59
|
}
|
|
60
|
+
if (dep.ecosystem === "composer") {
|
|
61
|
+
const full = `${dep.namespace || dep.groupId || ""}/${dep.name || dep.artifactId}`.toLowerCase();
|
|
62
|
+
return EOL_MAPPING.by_composer_name?.[full] || null;
|
|
63
|
+
}
|
|
64
|
+
if (dep.ecosystem === "pypi") {
|
|
65
|
+
return EOL_MAPPING.by_pypi_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
|
|
66
|
+
}
|
|
67
|
+
if (dep.ecosystem === "nuget") {
|
|
68
|
+
return EOL_MAPPING.by_nuget_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
|
|
69
|
+
}
|
|
60
70
|
const key = `${dep.groupId}:${dep.artifactId}`;
|
|
61
71
|
const direct = EOL_MAPPING.by_group_artifact?.[key];
|
|
62
72
|
if (direct) return direct;
|
|
@@ -245,7 +255,8 @@ async function checkOutdatedDeps(resolvedDeps, opts = {}) {
|
|
|
245
255
|
const cache = loadJsonCache(VERSION_CACHE_PATH);
|
|
246
256
|
const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < VERSION_CACHE_MAX_AGE_MS;
|
|
247
257
|
if (!fresh && !offline) cache.entries = {};
|
|
248
|
-
|
|
258
|
+
// Maven Central only — npm/composer/pypi/nuget have their own registries (codec.checkRegistry).
|
|
259
|
+
const list = [...resolvedDeps.values()].filter(d => d.version && !/\$\{|SNAPSHOT/i.test(d.version) && d.ecosystem === "maven");
|
|
249
260
|
const results = [];
|
|
250
261
|
|
|
251
262
|
// Progress indicator — Maven Central can serve hundreds of deps in a few
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Scans ALL Maven, npm, Yarn, Composer, Python, C#/.NET in a source tree ONE SHOT Alone like a big boy, deal with private deps, multi mvn modules, EOL, obsolete, outdated deps & give fix recos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "https://github.com/n8tz/fad-checker",
|
|
7
7
|
"author": "pp9Ping <pp9Ping@gmail.com>",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"p-limit": "^3.1.0",
|
|
29
29
|
"retire": "^5.4.2",
|
|
30
30
|
"rimraf": "^6.0.1",
|
|
31
|
+
"smol-toml": "^1.6.1",
|
|
31
32
|
"xml2js": "^0.6.2"
|
|
32
33
|
},
|
|
33
34
|
"engines": {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert");
|
|
3
|
+
const { resolveActiveCodecs } = require("../lib/codecs/select");
|
|
4
|
+
|
|
5
|
+
test("resolveActiveCodecs parses comma list and 'all'", () => {
|
|
6
|
+
const all = ["maven", "npm", "nuget", "composer", "pypi"];
|
|
7
|
+
assert.deepStrictEqual(resolveActiveCodecs("maven,pypi", all, {}), ["maven", "pypi"]);
|
|
8
|
+
assert.deepStrictEqual(resolveActiveCodecs("all", all, {}), all);
|
|
9
|
+
assert.deepStrictEqual(resolveActiveCodecs("auto", all, {}), all);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("resolveActiveCodecs honors --no-<id> flags", () => {
|
|
13
|
+
const all = ["maven", "npm", "nuget"];
|
|
14
|
+
assert.deepStrictEqual(resolveActiveCodecs("all", all, { noCodecs: ["npm"] }), ["maven", "nuget"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("--no-js aliases to npm+yarn", () => {
|
|
18
|
+
const all = ["maven", "npm", "yarn", "nuget"];
|
|
19
|
+
assert.deepStrictEqual(resolveActiveCodecs("all", all, { noJs: true }), ["maven", "nuget"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("legacy 'both' maps to maven+npm/yarn only", () => {
|
|
23
|
+
const all = ["maven", "npm", "yarn", "nuget", "pypi"];
|
|
24
|
+
assert.deepStrictEqual(resolveActiveCodecs("both", all, {}), ["maven", "npm", "yarn"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("explicit list intersects with available (auto-detect)", () => {
|
|
28
|
+
const detected = ["maven"]; // only maven detected on this tree
|
|
29
|
+
assert.deepStrictEqual(resolveActiveCodecs("maven,npm", detected, {}), ["maven"]);
|
|
30
|
+
});
|