fad-checker 1.0.6 → 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 (70) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/CLAUDE.md +27 -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 +108 -82
  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 +3 -6
  33. package/lib/cve-report.js +34 -70
  34. package/lib/dep-record.js +60 -0
  35. package/lib/osv.js +22 -14
  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-report.test.js +9 -0
  44. package/test/dep-record.test.js +31 -0
  45. package/test/fixtures/csharp-config/packages.config +4 -0
  46. package/test/fixtures/csharp-csproj/Directory.Packages.props +5 -0
  47. package/test/fixtures/csharp-csproj/app.csproj +7 -0
  48. package/test/fixtures/csharp-lock/packages.lock.json +6 -0
  49. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +2 -1
  50. package/test/fixtures/php-app/composer.json +5 -0
  51. package/test/fixtures/php-app/composer.lock +10 -0
  52. package/test/fixtures/polyglot/cs/packages.lock.json +1 -0
  53. package/test/fixtures/polyglot/js/package-lock.json +1 -0
  54. package/test/fixtures/polyglot/js/package.json +1 -0
  55. package/test/fixtures/polyglot/mvn/pom.xml +7 -0
  56. package/test/fixtures/polyglot/php/composer.lock +1 -0
  57. package/test/fixtures/polyglot/py/poetry.lock +3 -0
  58. package/test/fixtures/python-pipenv/Pipfile.lock +1 -0
  59. package/test/fixtures/python-poetry/poetry.lock +7 -0
  60. package/test/fixtures/python-reqs/requirements.txt +5 -0
  61. package/test/fixtures/python-uv/uv.lock +3 -0
  62. package/test/monorepo.test.js +1 -1
  63. package/test/npm-registry.test.js +1 -1
  64. package/test/npm.test.js +8 -4
  65. package/test/nuget.test.js +66 -0
  66. package/test/osv.test.js +62 -0
  67. package/test/pypi.test.js +72 -0
  68. package/test/webjar.test.js +1 -1
  69. /package/lib/{npm → codecs/npm}/parse.js +0 -0
  70. /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).
@@ -348,14 +349,24 @@ function makeRelative(absPath, srcRoot) {
348
349
  } catch { return absPath; }
349
350
  }
350
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
+
351
358
  function depDisplayParts(dep) {
352
- if (dep?.ecosystem === "npm") return { groupLine: "npm:", nameLine: dep.artifactId || "" };
353
- 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) };
354
363
  }
355
364
 
356
365
  function depDisplayName(dep) {
357
- if (dep?.ecosystem === "npm") return `npm:${dep.artifactId || ""}`;
358
- 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}`;
359
370
  }
360
371
 
361
372
  const REF_LABELS = {
@@ -626,65 +637,9 @@ function renderCveTable(cveMatches, opts = {}) {
626
637
  </table>`;
627
638
  }
628
639
 
629
- /**
630
- * Build a snippet of pom.xml the user can paste into their root POM's
631
- * <dependencyManagement> to pin a transitive to a safe version.
632
- */
633
- function dependencyManagementSnippet(items) {
634
- const inner = items.map(it => ` <dependency>
635
- <groupId>${esc(it.groupId)}</groupId>
636
- <artifactId>${esc(it.artifactId)}</artifactId>
637
- <version>${esc(it.fixVersion)}</version>
638
- </dependency>`).join("\n");
639
- return `<dependencyManagement>
640
- <dependencies>
641
- ${inner}
642
- </dependencies>
643
- </dependencyManagement>`;
644
- }
645
-
646
- function npmOverridesSnippet(items) {
647
- const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
648
- return `{
649
- "overrides": {
650
- ${lines}
651
- }
652
- }`;
653
- }
654
-
655
- function yarnResolutionsSnippet(items) {
656
- const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
657
- return `{
658
- "resolutions": {
659
- ${lines}
660
- }
661
- }`;
662
- }
663
-
664
- // Per-ecosystem fix-recipe metadata. Keys correspond to ecosystemType values.
665
- const ECO_RECIPE = {
666
- maven: {
667
- label: "Maven",
668
- pinSection: "A. Pin vulnerable transitives in <dependencyManagement>",
669
- pinIntro: cnt => `Paste into the root POM to immediately neutralise ${cnt} transitive vulnerabilit${cnt > 1 ? "ies" : "y"}:`,
670
- snippet: dependencyManagementSnippet,
671
- directSection: "B. Or update the direct dependencies pulling them in",
672
- },
673
- npm: {
674
- label: "npm",
675
- pinSection: "A. Pin vulnerable transitives via npm overrides",
676
- 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:`,
677
- snippet: npmOverridesSnippet,
678
- directSection: "B. Or update the direct dependencies (and run npm install)",
679
- },
680
- yarn: {
681
- label: "Yarn",
682
- pinSection: "A. Pin vulnerable transitives via yarn resolutions",
683
- 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:`,
684
- snippet: yarnResolutionsSnippet,
685
- directSection: "B. Or update the direct dependencies (and run yarn install)",
686
- },
687
- };
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");
688
643
 
689
644
  /**
690
645
  * From CVE matches (split direct/transitive) + outdatedResults + resolvedDeps,
@@ -790,7 +745,9 @@ function renderFixRecommendations(recos, allMatches = [], outdatedResults = [])
790
745
  }
791
746
 
792
747
  // Group by ecosystemType so each tool gets its own pin/update advice.
793
- 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: [] };
794
751
  for (const t of recos.transitivesList) {
795
752
  const et = ecoTypeOf(t);
796
753
  (byEco[et] || byEco.maven).trans.push(t);
@@ -800,7 +757,7 @@ function renderFixRecommendations(recos, allMatches = [], outdatedResults = [])
800
757
  (byEco[et] || byEco.maven).directs.push(d);
801
758
  }
802
759
 
803
- const ecoOrder = ["maven", "npm", "yarn"];
760
+ const ecoOrder = ORDER;
804
761
  const out = [directsSection];
805
762
  let letterIdx = 0;
806
763
  for (const eco of ecoOrder) {
@@ -1430,7 +1387,7 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1430
1387
  const out = [];
1431
1388
  for (const m of all) {
1432
1389
  const dep = m.dep;
1433
- const k = `${dep.ecosystem === "npm" ? "npm:" : dep.groupId + ":"}${dep.artifactId}`;
1390
+ const k = dep.coordKey || `${dep.ecosystem === "npm" ? "npm:" : dep.groupId + ":"}${dep.artifactId}`;
1434
1391
  if (seen.has(k)) continue;
1435
1392
  seen.add(k);
1436
1393
  out.push(m);
@@ -1459,12 +1416,17 @@ function renderFalsePositives(matches) {
1459
1416
  return intro + renderCveTable(matches);
1460
1417
  }
1461
1418
 
1462
- const ECO_LABELS = { maven: "Maven", npm: "npm (package-lock)", yarn: "Yarn" };
1463
- 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]]));
1464
1425
 
1465
1426
  function renderCveBySectionByEco(matches, chapterPrefix, srcRoot) {
1466
- const ecoOrder = ["maven", "npm", "yarn"];
1467
- const byEco = { maven: [], npm: [], yarn: [] };
1427
+ const ecoOrder = ORDER;
1428
+ const byEco = {};
1429
+ for (const id of ORDER) byEco[id] = [];
1468
1430
  for (const m of matches) {
1469
1431
  const et = m.dep?.ecosystemType || (m.dep?.ecosystem === "npm" ? "npm" : "maven");
1470
1432
  (byEco[et] || byEco.maven).push(m);
@@ -1843,5 +1805,7 @@ module.exports = {
1843
1805
  generateHtmlReport,
1844
1806
  generateWordReport,
1845
1807
  writeReports,
1808
+ depDisplayName,
1809
+ depDisplayParts,
1846
1810
  esc,
1847
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,
@@ -306,14 +315,11 @@ async function queryOsvForDeps(resolvedDeps, opts = {}) {
306
315
  // need a concrete version. Lockfiles always provide one; package.json-
307
316
  // only deps are skipped here.
308
317
  if (d.ecosystem === "npm" && /^[\^~>=<*]|^(latest|next|workspace:|git\+|file:|link:)/.test(ver)) continue;
309
- deps.push({
310
- groupId: d.groupId, artifactId: d.artifactId, version: ver,
311
- scope: d.scope, via: d.via, depth: d.depth, pomPaths: d.pomPaths,
312
- manifestPaths: d.manifestPaths,
313
- ecosystem: d.ecosystem || "maven",
314
- ecosystemType: d.ecosystemType,
315
- isDev: !!d.isDev,
316
- });
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 });
317
323
  }
318
324
  }
319
325
  return queryBatch(deps, opts);
@@ -325,5 +331,7 @@ module.exports = {
325
331
  vulnToMatch,
326
332
  severityFromOsv,
327
333
  fixVersionFromOsv,
334
+ osvEcosystemFor,
335
+ osvPkgName,
328
336
  OSV_CACHE_DIR,
329
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.6",
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
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Advanced edge-case + robustness tests for the composer/pypi/nuget codecs.
3
+ * Uses temp dirs so we can throw malformed inputs at the collectors.
4
+ */
5
+ const test = require("node:test");
6
+ const assert = require("node:assert");
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+
11
+ const composer = require("../lib/codecs/composer.codec");
12
+ const pypi = require("../lib/codecs/pypi.codec");
13
+ const nuget = require("../lib/codecs/nuget.codec");
14
+ const { parseRequirementsTxt, pep503 } = require("../lib/codecs/pypi/parse");
15
+
16
+ function tmp(prefix, files) {
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
18
+ for (const [name, content] of Object.entries(files)) {
19
+ const fp = path.join(dir, name);
20
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
21
+ fs.writeFileSync(fp, content);
22
+ }
23
+ return dir;
24
+ }
25
+
26
+ /* ---------------- composer ---------------- */
27
+
28
+ test("composer: ignoreTest drops dev packages; deps2Exclude filters by name", async () => {
29
+ const dir = tmp("comp-edge-", {
30
+ "composer.lock": JSON.stringify({
31
+ packages: [{ name: "guzzlehttp/guzzle", version: "7.4.5" }, { name: "acme/private", version: "1.0.0" }],
32
+ "packages-dev": [{ name: "phpunit/phpunit", version: "10.0.0" }],
33
+ }),
34
+ });
35
+ const noDev = await composer.collect(dir, { ignoreTest: true });
36
+ assert.ok(!noDev.deps.has("composer:phpunit/phpunit"), "dev dep dropped with ignoreTest");
37
+ assert.ok(noDev.deps.has("composer:guzzlehttp/guzzle"));
38
+ const excl = await composer.collect(dir, { deps2Exclude: /^acme\// });
39
+ assert.ok(!excl.deps.has("composer:acme/private"), "excluded by regex");
40
+ });
41
+
42
+ test("composer: malformed composer.lock does not throw, emits parse-error warning", async () => {
43
+ const dir = tmp("comp-bad-", { "composer.lock": "{ not valid json ]" });
44
+ const { deps, warnings } = await composer.collect(dir, {});
45
+ assert.strictEqual(deps.size, 0);
46
+ assert.ok(warnings.find(w => w.type === "parse-error"));
47
+ });
48
+
49
+ /* ---------------- pypi ---------------- */
50
+
51
+ test("pypi: requirements.txt handles extras, env markers, -r/-e/comments", () => {
52
+ const dir = tmp("py-req-", {
53
+ "requirements.txt": [
54
+ "# header",
55
+ "requests[security]==2.31.0", // extras → still pinned
56
+ 'django==4.2 ; python_version >= "3.8"', // env marker dropped
57
+ "flask>=2.0", // range → skip
58
+ "-r other.txt", // include → skip
59
+ "-e .", // editable → skip
60
+ "",
61
+ ].join("\n"),
62
+ });
63
+ const r = parseRequirementsTxt(path.join(dir, "requirements.txt"));
64
+ const names = r.deps.map(d => d.name).sort();
65
+ assert.deepStrictEqual(names, ["django", "requests"]);
66
+ assert.strictEqual(r.deps.find(d => d.name === "requests").version, "2.31.0");
67
+ assert.strictEqual(r.skipped, 1); // only flask>=2.0 counts as a skipped requirement spec
68
+ });
69
+
70
+ test("pypi: pep503 collapses mixed separators and casing", () => {
71
+ assert.strictEqual(pep503("Jinja2"), "jinja2");
72
+ assert.strictEqual(pep503("ruamel.yaml"), "ruamel-yaml");
73
+ assert.strictEqual(pep503("backports.zoneinfo"), "backports-zoneinfo");
74
+ });
75
+
76
+ test("pypi: lockfile wins over requirements.txt in the same directory", async () => {
77
+ const dir = tmp("py-prec-", {
78
+ "poetry.lock": '[[package]]\nname = "requests"\nversion = "2.31.0"\n',
79
+ "requirements.txt": "requests==1.0.0\n",
80
+ });
81
+ const { deps } = await pypi.collect(dir, {});
82
+ assert.strictEqual(deps.get("pypi:requests").version, "2.31.0", "poetry.lock should win over requirements.txt");
83
+ });
84
+
85
+ test("pypi: malformed poetry.lock does not throw", async () => {
86
+ const dir = tmp("py-bad-", { "poetry.lock": "this is = not [ valid toml" });
87
+ const { deps, warnings } = await pypi.collect(dir, {});
88
+ assert.strictEqual(deps.size, 0);
89
+ assert.ok(warnings.find(w => w.type === "parse-error"));
90
+ });
91
+
92
+ /* ---------------- nuget ---------------- */
93
+
94
+ test("nuget: packages.lock.json dedups the same id across target frameworks", async () => {
95
+ const dir = tmp("nuget-multi-tfm-", {
96
+ "packages.lock.json": JSON.stringify({
97
+ version: 1,
98
+ dependencies: {
99
+ "net6.0": { "Newtonsoft.Json": { type: "Direct", resolved: "13.0.1" } },
100
+ "net8.0": { "Newtonsoft.Json": { type: "Direct", resolved: "13.0.1" } },
101
+ },
102
+ }),
103
+ });
104
+ const { deps } = await nuget.collect(dir, {});
105
+ assert.strictEqual(deps.size, 1, "same id+version across TFMs collapses to one entry");
106
+ assert.ok(deps.has("nuget:newtonsoft.json"));
107
+ });
108
+
109
+ test("nuget: Version as a child element is read", async () => {
110
+ const dir = tmp("nuget-child-", {
111
+ "app.csproj": `<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><PackageReference Include="Serilog"><Version>2.12.0</Version></PackageReference></ItemGroup></Project>`,
112
+ });
113
+ const { deps } = await nuget.collect(dir, {});
114
+ assert.strictEqual(deps.get("nuget:serilog").version, "2.12.0");
115
+ });
116
+
117
+ test("nuget: packages.config collected via codec", async () => {
118
+ const dir = tmp("nuget-cfg-", {
119
+ "packages.config": `<?xml version="1.0"?><packages><package id="EntityFramework" version="6.4.4" /></packages>`,
120
+ });
121
+ const { deps } = await nuget.collect(dir, {});
122
+ assert.ok(deps.has("nuget:entityframework"));
123
+ assert.strictEqual(deps.get("nuget:entityframework").name, "EntityFramework");
124
+ });
125
+
126
+ test("nuget: malformed packages.lock.json does not throw", async () => {
127
+ const dir = tmp("nuget-bad-", { "packages.lock.json": "{ broken" });
128
+ const { deps, warnings } = await nuget.collect(dir, {});
129
+ assert.strictEqual(deps.size, 0);
130
+ assert.ok(warnings.find(w => w.type === "parse-error"));
131
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Cross-ecosystem integration tests — exercise all codecs together the way the
3
+ * orchestrator does: detect → collect → merge into one Map → shared services.
4
+ */
5
+ const test = require("node:test");
6
+ const assert = require("node:assert");
7
+ const path = require("path");
8
+ const { allCodecs, getCodec, detectCodecs } = require("../lib/codecs");
9
+ const { assertCodecShape } = require("../lib/codecs/codec.interface");
10
+ const { osvEcosystemFor, osvPkgName } = require("../lib/osv");
11
+ const { findEolProduct } = require("../lib/outdated");
12
+ const { generateHtmlReport } = require("../lib/cve-report");
13
+
14
+ const POLY = path.join(__dirname, "fixtures", "polyglot");
15
+
16
+ // Collect every active codec into one Map, exactly like fad-checker.js main().
17
+ async function collectAll(dir) {
18
+ const resolved = new Map();
19
+ const warnings = [];
20
+ for (const id of detectCodecs(dir).map(c => c.id)) {
21
+ if (id === "yarn") continue;
22
+ const { deps, warnings: w } = await getCodec(id).collect(dir, {});
23
+ for (const [k, v] of deps) resolved.set(k, v);
24
+ if (w) warnings.push(...w);
25
+ }
26
+ return { resolved, warnings };
27
+ }
28
+
29
+ test("every registered codec satisfies the interface contract", () => {
30
+ for (const c of allCodecs()) assertCodecShape(c);
31
+ });
32
+
33
+ test("detectCodecs finds all five ecosystems in the polyglot tree", () => {
34
+ const ids = detectCodecs(POLY).map(c => c.id).sort();
35
+ assert.deepStrictEqual(ids, ["composer", "maven", "npm", "nuget", "pypi"]);
36
+ });
37
+
38
+ test("collecting all codecs merges into one Map with NO coordKey collisions", async () => {
39
+ const { resolved } = await collectAll(POLY);
40
+ // Every key unique (Map guarantees) AND every record's coordKey === its key.
41
+ for (const [k, d] of resolved) {
42
+ assert.strictEqual(d.coordKey, k, `coordKey mismatch for ${k}`);
43
+ assert.ok(d.ecosystem, `missing ecosystem for ${k}`);
44
+ assert.ok(d.name, `missing name for ${k}`);
45
+ }
46
+ // All five ecosystems represented.
47
+ const ecos = new Set([...resolved.values()].map(d => d.ecosystem));
48
+ for (const e of ["maven", "npm", "composer", "pypi", "nuget"]) assert.ok(ecos.has(e), `missing ${e} deps`);
49
+ // Spot-check one coord per ecosystem.
50
+ assert.ok(resolved.has("org.apache.logging.log4j:log4j-core"));
51
+ assert.ok(resolved.has("npm:lodash"));
52
+ assert.ok(resolved.has("composer:guzzlehttp/guzzle"));
53
+ assert.ok(resolved.has("pypi:requests"));
54
+ assert.ok(resolved.has("nuget:newtonsoft.json"));
55
+ });
56
+
57
+ test("coordKeys from different ecosystems never collide even for same bare name", () => {
58
+ const { coordKeyFor } = require("../lib/dep-record");
59
+ const keys = [
60
+ coordKeyFor("maven", "org.x", "thing"),
61
+ coordKeyFor("npm", "", "thing"),
62
+ coordKeyFor("composer", "vendor", "thing"),
63
+ coordKeyFor("pypi", "", "thing"),
64
+ coordKeyFor("nuget", "", "thing"),
65
+ ];
66
+ assert.strictEqual(new Set(keys).size, keys.length, `collision among ${keys.join(", ")}`);
67
+ });
68
+
69
+ test("osv ecosystem + package name are correct for every codec", () => {
70
+ const cases = [
71
+ { dep: { ecosystem: "maven", namespace: "org.apache", name: "log4j-core", groupId: "org.apache", artifactId: "log4j-core" }, eco: "Maven", pkg: "org.apache:log4j-core" },
72
+ { dep: { ecosystem: "npm", namespace: "", name: "lodash", artifactId: "lodash" }, eco: "npm", pkg: "lodash" },
73
+ { dep: { ecosystem: "composer", namespace: "guzzlehttp", name: "guzzle" }, eco: "Packagist", pkg: "guzzlehttp/guzzle" },
74
+ { dep: { ecosystem: "pypi", namespace: "", name: "requests" }, eco: "PyPI", pkg: "requests" },
75
+ { dep: { ecosystem: "nuget", namespace: "", name: "Newtonsoft.Json" }, eco: "NuGet", pkg: "Newtonsoft.Json" },
76
+ ];
77
+ for (const c of cases) {
78
+ assert.strictEqual(osvEcosystemFor(c.dep), c.eco, `osv eco for ${c.dep.ecosystem}`);
79
+ assert.strictEqual(osvPkgName(c.dep), c.pkg, `osv pkg for ${c.dep.ecosystem}`);
80
+ }
81
+ });
82
+
83
+ test("findEolProduct dispatches per ecosystem to the right product", () => {
84
+ assert.strictEqual(findEolProduct({ ecosystem: "maven", groupId: "org.apache.logging.log4j", artifactId: "log4j-core", namespace: "org.apache.logging.log4j", name: "log4j-core" })?.product, "log4j");
85
+ assert.strictEqual(findEolProduct({ ecosystem: "npm", artifactId: "angular", name: "angular" })?.product, "angularjs");
86
+ assert.strictEqual(findEolProduct({ ecosystem: "composer", namespace: "symfony", name: "console" })?.product, "symfony");
87
+ assert.strictEqual(findEolProduct({ ecosystem: "pypi", name: "django" })?.product, "django");
88
+ assert.strictEqual(findEolProduct({ ecosystem: "nuget", name: "Microsoft.EntityFrameworkCore" })?.product, "efcore");
89
+ // unknown → null
90
+ assert.strictEqual(findEolProduct({ ecosystem: "pypi", name: "totally-unknown-pkg" }), null);
91
+ });
92
+
93
+ test("generateHtmlReport renders findings from every ecosystem without throwing", async () => {
94
+ const { resolved } = await collectAll(POLY);
95
+ const deps = [...resolved.values()];
96
+ const mk = d => ({ dep: d, cve: { id: `CVE-TEST-${d.ecosystem}`, severity: "HIGH", score: 7.5, description: `vuln in ${d.name}` }, source: "osv", confidence: "exact" });
97
+ const html = generateHtmlReport({
98
+ cveMatches: deps.map(mk),
99
+ eolResults: [], obsoleteResults: [], outdatedResults: [],
100
+ resolvedDeps: resolved,
101
+ projectInfo: { name: "polyglot", generatedAt: "2026-05-29", toolVersion: "test" },
102
+ });
103
+ assert.ok(html.startsWith("<!doctype html>"));
104
+ // Each ecosystem's section label should appear.
105
+ for (const label of ["Maven", "npm", "Composer", "PyPI", "NuGet"]) {
106
+ assert.ok(html.includes(label), `report should mention ${label}`);
107
+ }
108
+ // Ecosystem-tagged coordinates render (non-maven get a prefix).
109
+ assert.ok(html.includes("composer:") || html.includes("guzzlehttp/guzzle"));
110
+ });
111
+
112
+ test("every codec recipe renders a non-empty snippet from a sample finding", () => {
113
+ const sample = [{ groupId: "g", artifactId: "pkg", fixVersion: "1.2.3" }];
114
+ for (const c of allCodecs()) {
115
+ const out = c.recipe.snippet(sample);
116
+ assert.strictEqual(typeof out, "string");
117
+ assert.ok(out.length > 0, `${c.id} recipe snippet empty`);
118
+ }
119
+ });