fad-checker 1.0.5 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/CLAUDE.md +28 -9
- package/README.md +27 -13
- package/completions/fad-checker.bash +4 -1
- package/completions/fad-checker.zsh +9 -0
- package/data/eol-mapping.json +17 -0
- package/docs/ARCHITECTURE.md +35 -7
- package/docs/USAGE.md +15 -7
- package/docs/superpowers/plans/2026-05-29-codec-composer.md +556 -0
- package/docs/superpowers/plans/2026-05-29-codec-foundation.md +851 -0
- package/docs/superpowers/plans/2026-05-29-codec-nuget.md +432 -0
- package/docs/superpowers/plans/2026-05-29-codec-pypi.md +450 -0
- package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +251 -0
- package/fad-checker.js +109 -83
- package/lib/codecs/codec.interface.js +27 -0
- package/lib/codecs/composer/parse.js +59 -0
- package/lib/codecs/composer/registry.js +92 -0
- package/lib/codecs/composer.codec.js +93 -0
- package/lib/codecs/index.js +37 -0
- package/lib/codecs/maven.codec.js +71 -0
- package/lib/{npm → codecs/npm}/collect.js +32 -18
- package/lib/codecs/npm.codec.js +52 -0
- package/lib/codecs/nuget/parse.js +75 -0
- package/lib/codecs/nuget/registry.js +88 -0
- package/lib/codecs/nuget.codec.js +92 -0
- package/lib/codecs/pypi/parse.js +71 -0
- package/lib/codecs/pypi/registry.js +89 -0
- package/lib/codecs/pypi.codec.js +81 -0
- package/lib/codecs/recipes.js +108 -0
- package/lib/codecs/select.js +27 -0
- package/lib/codecs/yarn.codec.js +19 -0
- package/lib/cve-match.js +30 -14
- package/lib/cve-report.js +130 -83
- package/lib/dep-record.js +60 -0
- package/lib/osv.js +33 -19
- package/lib/outdated.js +13 -2
- package/package.json +3 -2
- package/test/cli-ecosystem.test.js +30 -0
- package/test/codec-edge-cases.test.js +131 -0
- package/test/codec-integration.test.js +119 -0
- package/test/codecs.test.js +92 -0
- package/test/composer.test.js +71 -0
- package/test/cve-match.test.js +40 -0
- package/test/cve-report.test.js +9 -0
- package/test/dep-record.test.js +31 -0
- package/test/fixtures/csharp-config/packages.config +4 -0
- package/test/fixtures/csharp-csproj/Directory.Packages.props +5 -0
- package/test/fixtures/csharp-csproj/app.csproj +7 -0
- package/test/fixtures/csharp-lock/packages.lock.json +6 -0
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +2 -1
- package/test/fixtures/php-app/composer.json +5 -0
- package/test/fixtures/php-app/composer.lock +10 -0
- package/test/fixtures/polyglot/cs/packages.lock.json +1 -0
- package/test/fixtures/polyglot/js/package-lock.json +1 -0
- package/test/fixtures/polyglot/js/package.json +1 -0
- package/test/fixtures/polyglot/mvn/pom.xml +7 -0
- package/test/fixtures/polyglot/php/composer.lock +1 -0
- package/test/fixtures/polyglot/py/poetry.lock +3 -0
- package/test/fixtures/python-pipenv/Pipfile.lock +1 -0
- package/test/fixtures/python-poetry/poetry.lock +7 -0
- package/test/fixtures/python-reqs/requirements.txt +5 -0
- package/test/fixtures/python-uv/uv.lock +3 -0
- package/test/monorepo.test.js +1 -1
- package/test/npm-registry.test.js +1 -1
- package/test/npm.test.js +8 -4
- package/test/nuget.test.js +66 -0
- package/test/osv.test.js +62 -0
- package/test/pypi.test.js +72 -0
- package/test/webjar.test.js +1 -1
- /package/lib/{npm → codecs/npm}/parse.js +0 -0
- /package/lib/{npm → codecs/npm}/registry.js +0 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { assertCodecShape } = require("../lib/codecs/codec.interface");
|
|
5
|
+
|
|
6
|
+
const COMPLETE_STUB = {
|
|
7
|
+
id: "x", label: "X", osvEcosystem: "npm",
|
|
8
|
+
manifestNames: ["x.json"],
|
|
9
|
+
detect: () => false,
|
|
10
|
+
collect: async () => ({ deps: new Map(), warnings: [] }),
|
|
11
|
+
coordKey: d => `x:${d.name}`,
|
|
12
|
+
formatCoord: d => d.name,
|
|
13
|
+
osvPackageName: d => d.name,
|
|
14
|
+
checkRegistry: async () => ({ outdated: [], deprecated: [] }),
|
|
15
|
+
resolveEolProduct: () => null,
|
|
16
|
+
recipe: { label: "X", pinSection: "", pinIntro: () => "", snippet: () => "", directSection: "" },
|
|
17
|
+
nativeScanners: [],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
test("assertCodecShape accepts a complete codec stub", () => {
|
|
21
|
+
assert.doesNotThrow(() => assertCodecShape(COMPLETE_STUB));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("assertCodecShape rejects a codec missing a required method", () => {
|
|
25
|
+
assert.throws(() => assertCodecShape({ id: "y" }), /missing|y/i);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const maven = require("../lib/codecs/maven.codec");
|
|
29
|
+
|
|
30
|
+
test("maven codec detects the simple fixture and collects deps with bare g:a coordKeys", async () => {
|
|
31
|
+
const dir = path.join(__dirname, "fixtures", "simple");
|
|
32
|
+
assert.strictEqual(maven.detect(dir), true);
|
|
33
|
+
const { deps } = await maven.collect(dir, {});
|
|
34
|
+
assert.ok(deps.size > 0);
|
|
35
|
+
for (const [k, d] of deps) {
|
|
36
|
+
assert.strictEqual(d.ecosystem, "maven");
|
|
37
|
+
assert.strictEqual(d.coordKey, k);
|
|
38
|
+
assert.ok(!k.startsWith("npm:") && !k.startsWith("nuget:"), `maven key ${k} must stay bare`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const npm = require("../lib/codecs/npm.codec");
|
|
43
|
+
|
|
44
|
+
test("npm codec collects from monorepo-mixed with npm: coordKeys", async () => {
|
|
45
|
+
const dir = path.join(__dirname, "fixtures", "monorepo-mixed");
|
|
46
|
+
assert.strictEqual(npm.detect(dir), true);
|
|
47
|
+
const { deps } = await npm.collect(dir, {});
|
|
48
|
+
assert.ok(deps.size > 0);
|
|
49
|
+
for (const [k, d] of deps) {
|
|
50
|
+
assert.ok(k.startsWith("npm:"), `key ${k} should be npm-namespaced`);
|
|
51
|
+
assert.strictEqual(d.ecosystem, "npm");
|
|
52
|
+
assert.strictEqual(d.coordKey, k);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("yarn codec is a no-op collector (npm codec does the JS scan)", async () => {
|
|
57
|
+
const yarn = require("../lib/codecs/yarn.codec");
|
|
58
|
+
assert.strictEqual(yarn.id, "yarn");
|
|
59
|
+
const { deps } = await yarn.collect("/whatever", {});
|
|
60
|
+
assert.strictEqual(deps.size, 0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const { getCodec, allCodecs, detectCodecs } = require("../lib/codecs");
|
|
64
|
+
|
|
65
|
+
test("registry exposes maven/npm/yarn and validates their shape", () => {
|
|
66
|
+
const ids = allCodecs().map(c => c.id).sort();
|
|
67
|
+
assert.deepStrictEqual(ids, ["composer", "maven", "npm", "nuget", "pypi", "yarn"]);
|
|
68
|
+
for (const c of allCodecs()) assertCodecShape(c);
|
|
69
|
+
assert.strictEqual(getCodec("maven").id, "maven");
|
|
70
|
+
assert.strictEqual(getCodec("nope"), null);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("detectCodecs finds maven+npm on monorepo-mixed, not yarn duplicate", () => {
|
|
74
|
+
const dir = path.join(__dirname, "fixtures", "monorepo-mixed");
|
|
75
|
+
const detected = detectCodecs(dir).map(c => c.id);
|
|
76
|
+
assert.ok(detected.includes("maven"));
|
|
77
|
+
assert.ok(detected.includes("npm"));
|
|
78
|
+
assert.ok(!detected.includes("yarn"));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const recipes = require("../lib/codecs/recipes");
|
|
82
|
+
|
|
83
|
+
test("each codec recipe exposes label + snippet function", () => {
|
|
84
|
+
for (const c of allCodecs()) {
|
|
85
|
+
assert.strictEqual(typeof c.recipe.label, "string");
|
|
86
|
+
assert.strictEqual(typeof c.recipe.snippet, "function");
|
|
87
|
+
}
|
|
88
|
+
// snippet output matches the legacy format
|
|
89
|
+
const xml = recipes.maven.snippet([{ groupId: "g", artifactId: "a", fixVersion: "1.0" }]);
|
|
90
|
+
assert.match(xml, /<dependencyManagement>/);
|
|
91
|
+
assert.match(xml, /<artifactId>a<\/artifactId>/);
|
|
92
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { parseComposerLock, parseComposerJson } = require("../lib/codecs/composer/parse");
|
|
5
|
+
|
|
6
|
+
const FIX = path.join(__dirname, "fixtures", "php-app");
|
|
7
|
+
|
|
8
|
+
test("parseComposerLock reads prod + dev packages, strips leading v", () => {
|
|
9
|
+
const r = parseComposerLock(path.join(FIX, "composer.lock"));
|
|
10
|
+
const byName = Object.fromEntries(r.deps.map(d => [d.name, d]));
|
|
11
|
+
assert.strictEqual(byName["guzzlehttp/guzzle"].version, "7.4.5");
|
|
12
|
+
assert.strictEqual(byName["guzzlehttp/guzzle"].scope, "prod");
|
|
13
|
+
assert.strictEqual(byName["symfony/console"].version, "6.2.10"); // "v6.2.10" → "6.2.10"
|
|
14
|
+
assert.strictEqual(byName["phpunit/phpunit"].scope, "dev");
|
|
15
|
+
assert.strictEqual(byName["phpunit/phpunit"].isDev, true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("parseComposerJson reads require + require-dev with pinned-vs-range info", () => {
|
|
19
|
+
const r = parseComposerJson(path.join(FIX, "composer.json"));
|
|
20
|
+
const byName = Object.fromEntries(r.deps.map(d => [d.name, d]));
|
|
21
|
+
assert.strictEqual(byName["monolog/monolog"].version, "2.9.1");
|
|
22
|
+
assert.strictEqual(byName["guzzlehttp/guzzle"].version, "^7.0");
|
|
23
|
+
assert.strictEqual(byName["phpunit/phpunit"].scope, "dev");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const { packagistToFindings } = require("../lib/codecs/composer/registry");
|
|
27
|
+
|
|
28
|
+
test("packagistToFindings extracts latest stable + abandoned flag", () => {
|
|
29
|
+
const pkg = {
|
|
30
|
+
"abandoned": "psr/log",
|
|
31
|
+
"versions": {
|
|
32
|
+
"2.9.1": { "version": "2.9.1" },
|
|
33
|
+
"3.0.0": { "version": "3.0.0" },
|
|
34
|
+
"dev-main": { "version": "dev-main" },
|
|
35
|
+
"2.8.0": { "version": "2.8.0" },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const f = packagistToFindings(pkg, { version: "2.9.1" });
|
|
39
|
+
assert.strictEqual(f.outdated.latest, "3.0.0");
|
|
40
|
+
assert.deepStrictEqual(f.abandoned, { replacement: "psr/log" });
|
|
41
|
+
|
|
42
|
+
const f2 = packagistToFindings({ "abandoned": true, "versions": { "1.0.0": {} } }, { version: "1.0.0" });
|
|
43
|
+
assert.deepStrictEqual(f2.abandoned, { replacement: null });
|
|
44
|
+
assert.strictEqual(f2.outdated, null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const composer = require("../lib/codecs/composer.codec");
|
|
48
|
+
const { assertCodecShape } = require("../lib/codecs/codec.interface");
|
|
49
|
+
|
|
50
|
+
test("composer codec: shape, detect, collect with composer:vendor/pkg coordKeys", async () => {
|
|
51
|
+
assertCodecShape(composer);
|
|
52
|
+
assert.strictEqual(composer.detect(FIX), true);
|
|
53
|
+
const { deps } = await composer.collect(FIX, {});
|
|
54
|
+
const g = deps.get("composer:guzzlehttp/guzzle");
|
|
55
|
+
assert.ok(g, "guzzle should be collected under composer:guzzlehttp/guzzle");
|
|
56
|
+
assert.strictEqual(g.ecosystem, "composer");
|
|
57
|
+
assert.strictEqual(g.namespace, "guzzlehttp");
|
|
58
|
+
assert.strictEqual(g.name, "guzzle");
|
|
59
|
+
assert.strictEqual(composer.osvPackageName(g), "guzzlehttp/guzzle");
|
|
60
|
+
assert.strictEqual(composer.formatCoord(g), "guzzlehttp/guzzle");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("composer collect falls back to composer.json (pinned only) with warning when no lock", async () => {
|
|
64
|
+
const os2 = require("os"); const fs2 = require("fs"); const p2 = require("path");
|
|
65
|
+
const dir = fs2.mkdtempSync(p2.join(os2.tmpdir(), "composer-nolock-"));
|
|
66
|
+
fs2.writeFileSync(p2.join(dir, "composer.json"), JSON.stringify({ name: "x/y", require: { "a/pinned": "1.2.3", "b/range": "^2.0" } }));
|
|
67
|
+
const { deps, warnings } = await composer.collect(dir, {});
|
|
68
|
+
assert.ok(deps.has("composer:a/pinned"));
|
|
69
|
+
assert.ok(!deps.has("composer:b/range"));
|
|
70
|
+
assert.ok(warnings.find(w => w.type === "no-lockfile"));
|
|
71
|
+
});
|
package/test/cve-match.test.js
CHANGED
|
@@ -133,6 +133,46 @@ test("collectResolvedDeps keeps highest version on conflict", async () => {
|
|
|
133
133
|
if (lang) assert.equal(lang.version, "3.12.0");
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
test("collectResolvedDeps records every distinct version (e.g. two profiles)", () => {
|
|
137
|
+
const depXml = (g, a, v) => ({ groupId: [g], artifactId: [a], version: [v], scope: ["compile"] });
|
|
138
|
+
const store = { byPath: { a: {}, b: {} }, byId: {} };
|
|
139
|
+
// Same g:a pinned to different versions in two modules/profiles.
|
|
140
|
+
const props = {
|
|
141
|
+
a: { properties: {}, dependencies: [depXml("com.x", "y", "1.0.0")], dependencyManagement: [] },
|
|
142
|
+
b: { properties: {}, dependencies: [depXml("com.x", "y", "2.0.0")], dependencyManagement: [] },
|
|
143
|
+
};
|
|
144
|
+
const deps = collectResolvedDeps(store, props, {});
|
|
145
|
+
const y = deps.get("com.x:y");
|
|
146
|
+
assert.ok(y, "the dep is collected once, keyed by g:a");
|
|
147
|
+
assert.equal(y.version, "2.0.0", "representative version is still the highest");
|
|
148
|
+
assert.deepEqual([...y.versions].sort(), ["1.0.0", "2.0.0"], "both distinct versions are tracked");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("matchDepsAgainstCves scans every distinct version, not just the highest", () => {
|
|
152
|
+
// A CVE that only affects the OLD version. Profiles pin 2.14.0 (vulnerable)
|
|
153
|
+
// and 2.20.0 (patched); the highest is patched, so deduping on max would
|
|
154
|
+
// hide the real exposure. Each distinct version must be scanned.
|
|
155
|
+
const idx = {
|
|
156
|
+
byPackageName: {
|
|
157
|
+
"org.apache.logging.log4j:log4j-core": [
|
|
158
|
+
{ id: "CVE-OLD-0001", severity: "CRITICAL", vendor: "apache", product: "log4j-core",
|
|
159
|
+
ranges: [{ version: "2.0", lessThan: "2.15.0", status: "affected" }] },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
byProduct: {},
|
|
163
|
+
};
|
|
164
|
+
const deps = new Map([
|
|
165
|
+
["org.apache.logging.log4j:log4j-core", {
|
|
166
|
+
groupId: "org.apache.logging.log4j", artifactId: "log4j-core",
|
|
167
|
+
version: "2.20.0", versions: ["2.14.0", "2.20.0"], scope: "compile", pomPaths: [],
|
|
168
|
+
}],
|
|
169
|
+
]);
|
|
170
|
+
const matches = matchDepsAgainstCves(deps, idx);
|
|
171
|
+
assert.equal(matches.length, 1, "vulnerable 2.14.0 must be flagged despite 2.20.0 being highest");
|
|
172
|
+
assert.equal(matches[0].cve.id, "CVE-OLD-0001");
|
|
173
|
+
assert.equal(matches[0].dep.version, "2.14.0", "match carries the actually-vulnerable version");
|
|
174
|
+
});
|
|
175
|
+
|
|
136
176
|
test("collectResolvedDeps --ignore-test drops test-scope deps", async () => {
|
|
137
177
|
const { store, props } = await pipeline(COMPLEX);
|
|
138
178
|
const withTest = collectResolvedDeps(store, props, {});
|
package/test/cve-report.test.js
CHANGED
|
@@ -169,3 +169,12 @@ test("writeReports writes both files to disk", async () => {
|
|
|
169
169
|
assert.ok(html.includes("log4j:log4j"));
|
|
170
170
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
171
171
|
});
|
|
172
|
+
|
|
173
|
+
const { depDisplayName, depDisplayParts } = require("../lib/cve-report");
|
|
174
|
+
|
|
175
|
+
test("depDisplayName delegates to codec and preserves legacy display", () => {
|
|
176
|
+
assert.strictEqual(depDisplayName({ ecosystem: "maven", ecosystemType: "maven", namespace: "org.apache", name: "log4j", groupId: "org.apache", artifactId: "log4j" }), "org.apache:log4j");
|
|
177
|
+
assert.strictEqual(depDisplayName({ ecosystem: "npm", ecosystemType: "npm", namespace: "", name: "lodash", artifactId: "lodash" }), "npm:lodash");
|
|
178
|
+
const parts = depDisplayParts({ ecosystem: "npm", ecosystemType: "npm", namespace: "", name: "lodash", artifactId: "lodash" });
|
|
179
|
+
assert.deepStrictEqual(parts, { groupLine: "npm:", nameLine: "lodash" });
|
|
180
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert");
|
|
3
|
+
const { makeDepRecord, coordKeyFor } = require("../lib/dep-record");
|
|
4
|
+
|
|
5
|
+
test("maven depRecord builds bare g:a coordKey and keeps groupId/artifactId aliases", () => {
|
|
6
|
+
const d = makeDepRecord({ ecosystem: "maven", namespace: "org.apache", name: "log4j", version: "2.14.0", manifestPath: "/p/pom.xml", scope: "compile" });
|
|
7
|
+
assert.strictEqual(d.coordKey, "org.apache:log4j"); // clé Maven brute (pas de préfixe)
|
|
8
|
+
assert.strictEqual(d.groupId, "org.apache"); // alias rétro-compat
|
|
9
|
+
assert.strictEqual(d.artifactId, "log4j"); // alias rétro-compat
|
|
10
|
+
assert.deepStrictEqual(d.versions, ["2.14.0"]);
|
|
11
|
+
assert.strictEqual(d.isDev, false);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("npm depRecord has empty namespace and npm-prefixed coordKey", () => {
|
|
15
|
+
const d = makeDepRecord({ ecosystem: "npm", namespace: "", name: "lodash", version: "4.17.20", manifestPath: "/p/package-lock.json", scope: "prod" });
|
|
16
|
+
assert.strictEqual(d.coordKey, "npm:lodash");
|
|
17
|
+
assert.strictEqual(d.groupId, "");
|
|
18
|
+
assert.strictEqual(d.artifactId, "lodash");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("coordKeyFor composes ecosystem + namespace + name", () => {
|
|
22
|
+
assert.strictEqual(coordKeyFor("composer", "guzzlehttp", "guzzle"), "composer:guzzlehttp/guzzle");
|
|
23
|
+
assert.strictEqual(coordKeyFor("pypi", "", "requests"), "pypi:requests");
|
|
24
|
+
assert.strictEqual(coordKeyFor("nuget", "", "Newtonsoft.Json"), "nuget:newtonsoft.json");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("pomPaths shares the manifestPaths array reference (push stays in sync)", () => {
|
|
28
|
+
const d = makeDepRecord({ ecosystem: "maven", namespace: "g", name: "a", version: "1.0", manifestPath: "/p/pom.xml" });
|
|
29
|
+
d.manifestPaths.push("/q/pom.xml");
|
|
30
|
+
assert.deepStrictEqual(d.pomPaths, ["/p/pom.xml", "/q/pom.xml"]);
|
|
31
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"packages": [
|
|
3
|
+
{ "name": "guzzlehttp/guzzle", "version": "7.4.5" },
|
|
4
|
+
{ "name": "monolog/monolog", "version": "2.9.1" },
|
|
5
|
+
{ "name": "symfony/console", "version": "v6.2.10" }
|
|
6
|
+
],
|
|
7
|
+
"packages-dev": [
|
|
8
|
+
{ "name": "phpunit/phpunit", "version": "10.1.0" }
|
|
9
|
+
]
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "version":1,"dependencies":{"net6.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}} }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name":"poly-js","lockfileVersion":3,"packages":{"node_modules/lodash":{"version":"4.17.21"}} }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name":"poly-js","dependencies":{"lodash":"^4.17.0"} }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
|
2
|
+
<modelVersion>4.0.0</modelVersion>
|
|
3
|
+
<groupId>com.acme</groupId><artifactId>poly</artifactId><version>1.0.0</version>
|
|
4
|
+
<dependencies>
|
|
5
|
+
<dependency><groupId>org.apache.logging.log4j</groupId><artifactId>log4j-core</artifactId><version>2.14.0</version></dependency>
|
|
6
|
+
</dependencies>
|
|
7
|
+
</project>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "packages":[{"name":"guzzlehttp/guzzle","version":"7.4.5"}],"packages-dev":[] }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "default": { "django": { "version": "==4.2.0" } }, "develop": { "pytest": { "version": "==7.4.0" } } }
|
package/test/monorepo.test.js
CHANGED
|
@@ -11,7 +11,7 @@ const assert = require("node:assert/strict");
|
|
|
11
11
|
const path = require("path");
|
|
12
12
|
const core = require("../lib/core");
|
|
13
13
|
const { collectResolvedDeps } = require("../lib/cve-match");
|
|
14
|
-
const { collectNpmDeps, hasJsManifests } = require("../lib/npm/collect");
|
|
14
|
+
const { collectNpmDeps, hasJsManifests } = require("../lib/codecs/npm/collect");
|
|
15
15
|
|
|
16
16
|
const FIX = path.join(__dirname, "fixtures", "monorepo-mixed");
|
|
17
17
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { test } = require("node:test");
|
|
2
2
|
const assert = require("node:assert/strict");
|
|
3
|
-
const { packumentToFindings } = require("../lib/npm/registry");
|
|
3
|
+
const { packumentToFindings } = require("../lib/codecs/npm/registry");
|
|
4
4
|
|
|
5
5
|
const dep = (name, version) => ({ ecosystem: "npm", groupId: "", artifactId: name, version });
|
|
6
6
|
|