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.
Files changed (71) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/CLAUDE.md +28 -9
  3. package/README.md +27 -13
  4. package/completions/fad-checker.bash +4 -1
  5. package/completions/fad-checker.zsh +9 -0
  6. package/data/eol-mapping.json +17 -0
  7. package/docs/ARCHITECTURE.md +35 -7
  8. package/docs/USAGE.md +15 -7
  9. package/docs/superpowers/plans/2026-05-29-codec-composer.md +556 -0
  10. package/docs/superpowers/plans/2026-05-29-codec-foundation.md +851 -0
  11. package/docs/superpowers/plans/2026-05-29-codec-nuget.md +432 -0
  12. package/docs/superpowers/plans/2026-05-29-codec-pypi.md +450 -0
  13. package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +251 -0
  14. package/fad-checker.js +109 -83
  15. package/lib/codecs/codec.interface.js +27 -0
  16. package/lib/codecs/composer/parse.js +59 -0
  17. package/lib/codecs/composer/registry.js +92 -0
  18. package/lib/codecs/composer.codec.js +93 -0
  19. package/lib/codecs/index.js +37 -0
  20. package/lib/codecs/maven.codec.js +71 -0
  21. package/lib/{npm → codecs/npm}/collect.js +32 -18
  22. package/lib/codecs/npm.codec.js +52 -0
  23. package/lib/codecs/nuget/parse.js +75 -0
  24. package/lib/codecs/nuget/registry.js +88 -0
  25. package/lib/codecs/nuget.codec.js +92 -0
  26. package/lib/codecs/pypi/parse.js +71 -0
  27. package/lib/codecs/pypi/registry.js +89 -0
  28. package/lib/codecs/pypi.codec.js +81 -0
  29. package/lib/codecs/recipes.js +108 -0
  30. package/lib/codecs/select.js +27 -0
  31. package/lib/codecs/yarn.codec.js +19 -0
  32. package/lib/cve-match.js +30 -14
  33. package/lib/cve-report.js +130 -83
  34. package/lib/dep-record.js +60 -0
  35. package/lib/osv.js +33 -19
  36. package/lib/outdated.js +13 -2
  37. package/package.json +3 -2
  38. package/test/cli-ecosystem.test.js +30 -0
  39. package/test/codec-edge-cases.test.js +131 -0
  40. package/test/codec-integration.test.js +119 -0
  41. package/test/codecs.test.js +92 -0
  42. package/test/composer.test.js +71 -0
  43. package/test/cve-match.test.js +40 -0
  44. package/test/cve-report.test.js +9 -0
  45. package/test/dep-record.test.js +31 -0
  46. package/test/fixtures/csharp-config/packages.config +4 -0
  47. package/test/fixtures/csharp-csproj/Directory.Packages.props +5 -0
  48. package/test/fixtures/csharp-csproj/app.csproj +7 -0
  49. package/test/fixtures/csharp-lock/packages.lock.json +6 -0
  50. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +2 -1
  51. package/test/fixtures/php-app/composer.json +5 -0
  52. package/test/fixtures/php-app/composer.lock +10 -0
  53. package/test/fixtures/polyglot/cs/packages.lock.json +1 -0
  54. package/test/fixtures/polyglot/js/package-lock.json +1 -0
  55. package/test/fixtures/polyglot/js/package.json +1 -0
  56. package/test/fixtures/polyglot/mvn/pom.xml +7 -0
  57. package/test/fixtures/polyglot/php/composer.lock +1 -0
  58. package/test/fixtures/polyglot/py/poetry.lock +3 -0
  59. package/test/fixtures/python-pipenv/Pipfile.lock +1 -0
  60. package/test/fixtures/python-poetry/poetry.lock +7 -0
  61. package/test/fixtures/python-reqs/requirements.txt +5 -0
  62. package/test/fixtures/python-uv/uv.lock +3 -0
  63. package/test/monorepo.test.js +1 -1
  64. package/test/npm-registry.test.js +1 -1
  65. package/test/npm.test.js +8 -4
  66. package/test/nuget.test.js +66 -0
  67. package/test/osv.test.js +62 -0
  68. package/test/pypi.test.js +72 -0
  69. package/test/webjar.test.js +1 -1
  70. /package/lib/{npm → codecs/npm}/parse.js +0 -0
  71. /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
- if (dep?.ecosystem === "npm") return { groupLine: "npm:", nameLine: dep.artifactId || "" };
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
- if (dep?.ecosystem === "npm") return `npm:${dep.artifactId || ""}`;
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
- * Build a snippet of pom.xml the user can paste into their root POM's
630
- * <dependencyManagement> to pin a transitive to a safe version.
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
- const byEco = { maven: { trans: [], directs: [] }, npm: { trans: [], directs: [] }, yarn: { trans: [], directs: [] } };
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 = ["maven", "npm", "yarn"];
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
- function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats, eolResults, obsoleteResults, outdatedResults, warnings, totalDeps, topCriticalMatches }) {
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
- const ECO_LABELS = { maven: "Maven", npm: "npm (package-lock)", yarn: "Yarn" };
1397
- const ECO_MANIFEST_KIND = { maven: "pom.xml", npm: "package-lock.json", yarn: "yarn.lock" };
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 = ["maven", "npm", "yarn"];
1401
- const byEco = { maven: [], npm: [], yarn: [] };
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
- async function copyTable(table, btn){
1492
- var clone = inlineStylesForWord(table);
1493
- var html = wordWrapper(clone.outerHTML);
1494
- var tsv = tableToTsv(table);
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], { type: 'text/html' }),
1501
- 'text/plain': new Blob([tsv], { type: 'text/plain' })
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') — gets the HTML
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 = clone.outerHTML;
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
- /** Build the OSV package-name key for a dep (Maven uses g:a, npm uses bare name). */
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-aware query: OSV uses "npm" for npm packages (name only,
159
- // no groupId), "Maven" for Maven (name = "g:a"). The dep record's
160
- // `ecosystem` field defaults to "maven" — npm collector sets "npm".
161
- const ecosystem = d.ecosystem === "npm" ? "npm" : "Maven";
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
- // 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
- });
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
- const list = [...resolvedDeps.values()].filter(d => d.version && !/\$\{|SNAPSHOT/i.test(d.version) && d.ecosystem !== "npm");
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": "1.0.5",
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",
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
+ });