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.
- package/CHANGELOG.md +47 -0
- package/CLAUDE.md +27 -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 +108 -82
- 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 +3 -6
- package/lib/cve-report.js +34 -70
- package/lib/dep-record.js +60 -0
- package/lib/osv.js +22 -14
- 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-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).
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1463
|
-
|
|
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 =
|
|
1467
|
-
const byEco = {
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|