fad-checker 1.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/CLAUDE.md +126 -0
- package/README.md +279 -0
- package/completions/fad-check.bash +23 -0
- package/completions/fad-check.zsh +27 -0
- package/data/cpe-coord-map.json +112 -0
- package/data/eol-mapping.json +34 -0
- package/data/known-obsolete.json +142 -0
- package/data/known-public-namespaces.json +79 -0
- package/docs/ARCHITECTURE.md +152 -0
- package/docs/USAGE.md +179 -0
- package/fad-check.js +665 -0
- package/lib/cache-archive.js +107 -0
- package/lib/config.js +87 -0
- package/lib/core.js +317 -0
- package/lib/cpe.js +287 -0
- package/lib/cve-download.js +369 -0
- package/lib/cve-match.js +228 -0
- package/lib/cve-report.js +1455 -0
- package/lib/maven-repo.js +134 -0
- package/lib/maven-version.js +153 -0
- package/lib/npm/collect.js +224 -0
- package/lib/npm/parse.js +291 -0
- package/lib/nvd.js +239 -0
- package/lib/osv.js +298 -0
- package/lib/outdated.js +265 -0
- package/lib/retire.js +211 -0
- package/lib/scan-completeness.js +67 -0
- package/lib/snyk.js +127 -0
- package/lib/transitive.js +410 -0
- package/package.json +35 -0
- package/test/core.test.js +153 -0
- package/test/cpe.test.js +148 -0
- package/test/cve-download.test.js +39 -0
- package/test/cve-match.test.js +108 -0
- package/test/cve-report.test.js +79 -0
- package/test/fixtures/complex-enterprise/api/pom.xml +32 -0
- package/test/fixtures/complex-enterprise/build/pom.xml +46 -0
- package/test/fixtures/complex-enterprise/dao/pom.xml +37 -0
- package/test/fixtures/complex-enterprise/pom.xml +66 -0
- package/test/fixtures/complex-enterprise/web/pom.xml +77 -0
- package/test/fixtures/cve-samples/cve-non-java.json +19 -0
- package/test/fixtures/cve-samples/cve-product-only.json +31 -0
- package/test/fixtures/cve-samples/cve-with-packagename.json +37 -0
- package/test/fixtures/cve-samples/nvd-log4shell.json +40 -0
- package/test/fixtures/cve-samples/nvd-npm-lodash.json +22 -0
- package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +26 -0
- package/test/fixtures/monorepo-mixed/packages/cli/package.json +14 -0
- package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +41 -0
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +9 -0
- package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +71 -0
- package/test/fixtures/monorepo-mixed/packages/web-app/package.json +17 -0
- package/test/fixtures/monorepo-mixed/pom.xml +29 -0
- package/test/fixtures/monorepo-mixed/services/api/pom.xml +27 -0
- package/test/fixtures/monorepo-mixed/services/worker/pom.xml +28 -0
- package/test/fixtures/private-lib-detection/core/pom.xml +26 -0
- package/test/fixtures/private-lib-detection/plugin/pom.xml +23 -0
- package/test/fixtures/private-lib-detection/pom.xml +35 -0
- package/test/fixtures/simple/app/pom.xml +28 -0
- package/test/fixtures/simple/lib/pom.xml +18 -0
- package/test/fixtures/simple/pom.xml +24 -0
- package/test/maven-repo.test.js +111 -0
- package/test/maven-version.test.js +48 -0
- package/test/monorepo.test.js +132 -0
- package/test/npm.test.js +146 -0
- package/test/outdated.test.js +56 -0
- package/test/snyk.test.js +64 -0
- package/test/transitive.test.js +305 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const { test } = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const {
|
|
4
|
+
buildRepoList,
|
|
5
|
+
splitUrlAuth,
|
|
6
|
+
authHeader,
|
|
7
|
+
tryRepos,
|
|
8
|
+
fetchPomFromRepos,
|
|
9
|
+
fetchMavenMetadata,
|
|
10
|
+
MAVEN_CENTRAL,
|
|
11
|
+
} = require("../lib/maven-repo");
|
|
12
|
+
|
|
13
|
+
test("splitUrlAuth pulls user:pass out of a URL", () => {
|
|
14
|
+
const { url, auth } = splitUrlAuth("https://alice:s3cr3t@nexus.acme.com/repository/maven-public/");
|
|
15
|
+
assert.equal(auth, "alice:s3cr3t");
|
|
16
|
+
assert.ok(!/alice:s3cr3t/.test(url));
|
|
17
|
+
assert.ok(url.startsWith("https://nexus.acme.com/"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("splitUrlAuth no-op when URL has no auth", () => {
|
|
21
|
+
const { url, auth } = splitUrlAuth("https://repo1.maven.org/maven2/");
|
|
22
|
+
assert.equal(auth, null);
|
|
23
|
+
assert.equal(url, "https://repo1.maven.org/maven2/");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("authHeader returns Basic <base64>", () => {
|
|
27
|
+
assert.equal(authHeader("alice:s3cr3t"), "Basic " + Buffer.from("alice:s3cr3t").toString("base64"));
|
|
28
|
+
assert.equal(authHeader(null), null);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("buildRepoList puts user repos first, Central last, dedupes by URL, normalises trailing slash", () => {
|
|
32
|
+
const list = buildRepoList(
|
|
33
|
+
[
|
|
34
|
+
{ name: "nexus", url: "https://nexus.acme.com/repository/maven-public" }, // no trailing /
|
|
35
|
+
{ name: "jboss", url: "https://repository.jboss.org/nexus/content/groups/public/" },
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
{ url: "https://nexus.acme.com/repository/maven-public/" }, // dup, with trailing /
|
|
39
|
+
{ url: "https://maven.atlassian.com/" },
|
|
40
|
+
],
|
|
41
|
+
);
|
|
42
|
+
assert.equal(list[0].name, "nexus");
|
|
43
|
+
assert.equal(list[0].url, "https://nexus.acme.com/repository/maven-public/");
|
|
44
|
+
assert.equal(list[1].name, "jboss");
|
|
45
|
+
// The dup of "nexus" is dropped
|
|
46
|
+
assert.equal(list.filter(r => r.name === "nexus").length, 1);
|
|
47
|
+
// atlassian is in the middle (extra repo, before Central)
|
|
48
|
+
assert.ok(list.some(r => r.url === "https://maven.atlassian.com/"));
|
|
49
|
+
// Maven Central is last
|
|
50
|
+
assert.equal(list[list.length - 1].url, MAVEN_CENTRAL.url);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("buildRepoList strips and stores embedded user:pass auth", () => {
|
|
54
|
+
const list = buildRepoList([
|
|
55
|
+
{ name: "private", url: "https://bob:hunter2@nexus.acme.com/repository/private/" },
|
|
56
|
+
]);
|
|
57
|
+
assert.equal(list[0].url, "https://nexus.acme.com/repository/private/");
|
|
58
|
+
assert.equal(list[0].auth, "bob:hunter2");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("tryRepos returns the first 2xx, skipping 404s", async () => {
|
|
62
|
+
const calls = [];
|
|
63
|
+
const fetcher = async (url) => {
|
|
64
|
+
calls.push(url);
|
|
65
|
+
if (url.startsWith("https://nexus.acme/")) return { ok: false, status: 404 };
|
|
66
|
+
if (url.startsWith("https://repo1.maven.org/")) return { ok: true, status: 200, text: async () => "<project/>" };
|
|
67
|
+
return { ok: false, status: 500 };
|
|
68
|
+
};
|
|
69
|
+
const repos = buildRepoList([{ name: "nexus", url: "https://nexus.acme/" }]);
|
|
70
|
+
const hit = await tryRepos(repos, "log4j/log4j/1.2.17/log4j-1.2.17.pom", { fetcher, readBody: true });
|
|
71
|
+
assert.ok(hit, "should hit Central after Nexus 404");
|
|
72
|
+
assert.equal(hit.repo.url, MAVEN_CENTRAL.url);
|
|
73
|
+
assert.equal(hit.body, "<project/>");
|
|
74
|
+
assert.equal(calls.length, 2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("tryRepos returns null when no repo answers 2xx", async () => {
|
|
78
|
+
const fetcher = async () => ({ ok: false, status: 404 });
|
|
79
|
+
const repos = buildRepoList([{ name: "a", url: "https://a.example/" }, { name: "b", url: "https://b.example/" }]);
|
|
80
|
+
const hit = await tryRepos(repos, "g/a/1/a-1.pom", { fetcher });
|
|
81
|
+
assert.equal(hit, null);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("tryRepos sends Basic auth header for repos with creds", async () => {
|
|
85
|
+
let seenAuth = null;
|
|
86
|
+
const fetcher = async (url, init) => {
|
|
87
|
+
seenAuth = init?.headers?.Authorization || null;
|
|
88
|
+
return { ok: true, status: 200, text: async () => "ok" };
|
|
89
|
+
};
|
|
90
|
+
const repos = buildRepoList([{ name: "p", url: "https://x:y@nexus.acme/" }]);
|
|
91
|
+
await tryRepos(repos, "g/a/1/a-1.pom", { fetcher });
|
|
92
|
+
assert.equal(seenAuth, "Basic " + Buffer.from("x:y").toString("base64"));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("fetchPomFromRepos constructs the standard Maven path", async () => {
|
|
96
|
+
const seen = [];
|
|
97
|
+
const fetcher = async (url) => { seen.push(url); return { ok: false, status: 404 }; };
|
|
98
|
+
const repos = buildRepoList([{ name: "a", url: "https://a.example/" }]);
|
|
99
|
+
await fetchPomFromRepos(repos, "org.apache.logging.log4j", "log4j-core", "2.17.0", { fetcher });
|
|
100
|
+
assert.ok(seen[0].endsWith("org/apache/logging/log4j/log4j-core/2.17.0/log4j-core-2.17.0.pom"),
|
|
101
|
+
`unexpected URL: ${seen[0]}`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("fetchMavenMetadata hits the maven-metadata.xml path", async () => {
|
|
105
|
+
const seen = [];
|
|
106
|
+
const fetcher = async (url) => { seen.push(url); return { ok: false, status: 404 }; };
|
|
107
|
+
const repos = buildRepoList([{ name: "a", url: "https://a.example/" }]);
|
|
108
|
+
await fetchMavenMetadata(repos, "org.apache.logging.log4j", "log4j-core", { fetcher });
|
|
109
|
+
assert.ok(seen[0].endsWith("org/apache/logging/log4j/log4j-core/maven-metadata.xml"),
|
|
110
|
+
`unexpected URL: ${seen[0]}`);
|
|
111
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const { test } = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { parseMavenVersion, compareMavenVersions, isVersionAffected, parseRange } = require("../lib/maven-version");
|
|
4
|
+
|
|
5
|
+
test("parseMavenVersion returns segments", () => {
|
|
6
|
+
const v = parseMavenVersion("2.14.0");
|
|
7
|
+
assert.equal(v.original, "2.14.0");
|
|
8
|
+
assert.deepEqual(v.segments.map(s => s.value), [2, 14, 0]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("compareMavenVersions basic numeric ordering", () => {
|
|
12
|
+
assert.equal(compareMavenVersions("1.0.0", "1.0.0"), 0);
|
|
13
|
+
assert.equal(compareMavenVersions("1.0.0", "1.0.1"), -1);
|
|
14
|
+
assert.equal(compareMavenVersions("2.0.0", "1.9.9"), 1);
|
|
15
|
+
assert.equal(compareMavenVersions("1.10.0", "1.9.0"), 1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("compareMavenVersions handles qualifiers", () => {
|
|
19
|
+
assert.equal(compareMavenVersions("1.0.0-SNAPSHOT", "1.0.0"), -1);
|
|
20
|
+
assert.equal(compareMavenVersions("1.0.0-rc1", "1.0.0"), -1);
|
|
21
|
+
assert.equal(compareMavenVersions("1.0.0-alpha", "1.0.0-beta"), -1);
|
|
22
|
+
assert.equal(compareMavenVersions("5.3.20.Final", "5.3.20"), 0);
|
|
23
|
+
assert.equal(compareMavenVersions("5.3.20.RELEASE", "5.3.20.Final"), 0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("isVersionAffected respects [version, lessThan)", () => {
|
|
27
|
+
const spec = { version: "2.0", status: "affected", lessThan: "2.15.0" };
|
|
28
|
+
assert.equal(isVersionAffected("2.14.0", spec), true);
|
|
29
|
+
assert.equal(isVersionAffected("2.15.0", spec), false, "lessThan is exclusive");
|
|
30
|
+
assert.equal(isVersionAffected("1.5.0", spec), false, "below lower bound");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("isVersionAffected with lessThanOrEqual is inclusive", () => {
|
|
34
|
+
const spec = { version: "1.0", status: "affected", lessThanOrEqual: "1.5.0" };
|
|
35
|
+
assert.equal(isVersionAffected("1.5.0", spec), true);
|
|
36
|
+
assert.equal(isVersionAffected("1.5.1", spec), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("isVersionAffected returns false when status != affected", () => {
|
|
40
|
+
const spec = { version: "1.0", lessThan: "2.0", status: "unaffected" };
|
|
41
|
+
assert.equal(isVersionAffected("1.5", spec), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("parseRange handles Maven range syntax", () => {
|
|
45
|
+
assert.deepEqual(parseRange("1.2.3"), { exact: "1.2.3" });
|
|
46
|
+
assert.deepEqual(parseRange("[1.0,2.0)"), { lower: "1.0", lowerInclusive: true, upper: "2.0", upperInclusive: false });
|
|
47
|
+
assert.deepEqual(parseRange("(,1.5]"), { lower: null, lowerInclusive: false, upper: "1.5", upperInclusive: true });
|
|
48
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end test against fixtures/monorepo-mixed:
|
|
3
|
+
* - 1 parent pom + 1 BOM module + 2 Maven submodules
|
|
4
|
+
* - 1 npm package (package-lock v3) with prod/dev/peer deps
|
|
5
|
+
* - 1 yarn-v1 package with prod/dev deps + a private @acme/* dep
|
|
6
|
+
*
|
|
7
|
+
* Drives the same code paths as `fad-check --report` minus the network calls.
|
|
8
|
+
*/
|
|
9
|
+
const { test } = require("node:test");
|
|
10
|
+
const assert = require("node:assert/strict");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const core = require("../lib/core");
|
|
13
|
+
const { collectResolvedDeps } = require("../lib/cve-match");
|
|
14
|
+
const { collectNpmDeps, hasJsManifests } = require("../lib/npm/collect");
|
|
15
|
+
|
|
16
|
+
const FIX = path.join(__dirname, "fixtures", "monorepo-mixed");
|
|
17
|
+
|
|
18
|
+
async function loadMavenTree(src) {
|
|
19
|
+
const pomFiles = core.findPomFiles(src);
|
|
20
|
+
const meta = core.newMetadataStore();
|
|
21
|
+
const propsByPom = {};
|
|
22
|
+
for (const pom of pomFiles) await core.parsePom(pom, meta);
|
|
23
|
+
for (const pom of Object.keys(meta.byPath)) {
|
|
24
|
+
await core.getAllInheritedProps(pom, meta, propsByPom);
|
|
25
|
+
}
|
|
26
|
+
return { meta, propsByPom, pomFiles };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("hasJsManifests detects the JS packages under the monorepo", () => {
|
|
30
|
+
assert.equal(hasJsManifests(FIX), true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("Maven side: parses parent + BOM + submodules", async () => {
|
|
34
|
+
const { meta, pomFiles } = await loadMavenTree(FIX);
|
|
35
|
+
assert.equal(pomFiles.length, 4); // root + bom + api + worker
|
|
36
|
+
assert.ok(meta.byId["com.acme:monorepo-parent"]);
|
|
37
|
+
assert.ok(meta.byId["com.acme:common-bom"]);
|
|
38
|
+
assert.ok(meta.byId["com.acme:api"]);
|
|
39
|
+
assert.ok(meta.byId["com.acme:worker"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("Maven side: BOM-imported versions surface in api's resolved deps", async () => {
|
|
43
|
+
const { meta, propsByPom } = await loadMavenTree(FIX);
|
|
44
|
+
const resolved = collectResolvedDeps(meta, propsByPom, {});
|
|
45
|
+
const databind = resolved.get("com.fasterxml.jackson.core:jackson-databind");
|
|
46
|
+
assert.ok(databind, "jackson-databind should be resolved via BOM");
|
|
47
|
+
// Version arrives through ${jackson.version} property defined on the root parent
|
|
48
|
+
assert.equal(databind.version, "2.9.10");
|
|
49
|
+
assert.equal(databind.ecosystem, "maven");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("Maven side: exclude regex strips com.acme.private but keeps public deps", async () => {
|
|
53
|
+
const { meta, propsByPom } = await loadMavenTree(FIX);
|
|
54
|
+
const resolved = collectResolvedDeps(meta, propsByPom, {
|
|
55
|
+
deps2Exclude: /^com\.acme\.private/,
|
|
56
|
+
});
|
|
57
|
+
assert.equal(resolved.has("com.acme.private:internal-auth"), false);
|
|
58
|
+
assert.ok(resolved.has("com.fasterxml.jackson.core:jackson-databind"));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("Maven side: --ignore-test drops junit test scope", async () => {
|
|
62
|
+
const { meta, propsByPom } = await loadMavenTree(FIX);
|
|
63
|
+
const withTest = collectResolvedDeps(meta, propsByPom, {});
|
|
64
|
+
const noTest = collectResolvedDeps(meta, propsByPom, { ignoreTest: true });
|
|
65
|
+
assert.ok(withTest.has("junit:junit"));
|
|
66
|
+
assert.equal(noTest.has("junit:junit"), false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("JS side: both packages discovered, namespaced keys, no Maven collision", () => {
|
|
70
|
+
const npm = collectNpmDeps(FIX, {});
|
|
71
|
+
assert.ok(npm.has("npm:axios"));
|
|
72
|
+
assert.ok(npm.has("npm:chalk"));
|
|
73
|
+
assert.ok(npm.has("npm:@acme/private-utils"));
|
|
74
|
+
// No accidental collision with Maven g:a keys
|
|
75
|
+
for (const key of npm.keys()) assert.ok(key.startsWith("npm:"), `${key} should be namespaced`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("Combined: merged Map preserves both ecosystems and is queryable as one", async () => {
|
|
79
|
+
const { meta, propsByPom } = await loadMavenTree(FIX);
|
|
80
|
+
const combined = collectResolvedDeps(meta, propsByPom, {});
|
|
81
|
+
const npm = collectNpmDeps(FIX, {});
|
|
82
|
+
for (const [k, v] of npm) combined.set(k, v);
|
|
83
|
+
|
|
84
|
+
// Maven and npm coords coexist
|
|
85
|
+
assert.ok(combined.get("com.fasterxml.jackson.core:jackson-databind"));
|
|
86
|
+
assert.ok(combined.get("npm:axios"));
|
|
87
|
+
// Distinct ecosystems
|
|
88
|
+
assert.equal(combined.get("com.fasterxml.jackson.core:jackson-databind").ecosystem, "maven");
|
|
89
|
+
assert.equal(combined.get("npm:axios").ecosystem, "npm");
|
|
90
|
+
// Total deps from both sides
|
|
91
|
+
assert.ok(combined.size >= 10, `expected ≥10 combined deps, got ${combined.size}`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("Combined: shared exclusion regex applies per-ecosystem appropriately", async () => {
|
|
95
|
+
// Maven private prefix
|
|
96
|
+
const { meta, propsByPom } = await loadMavenTree(FIX);
|
|
97
|
+
const mvn = collectResolvedDeps(meta, propsByPom, { deps2Exclude: /^com\.acme\.private/ });
|
|
98
|
+
assert.equal(mvn.has("com.acme.private:internal-auth"), false);
|
|
99
|
+
|
|
100
|
+
// npm uses a different convention (@scope) — own regex matches it
|
|
101
|
+
const npm = collectNpmDeps(FIX, { deps2Exclude: /^@acme\// });
|
|
102
|
+
assert.equal(npm.has("npm:@acme/private-utils"), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("Combined: a sample CVE match flow runs without throwing on a mixed Map", async () => {
|
|
106
|
+
const { matchDepsAgainstCves } = require("../lib/cve-match");
|
|
107
|
+
const { meta, propsByPom } = await loadMavenTree(FIX);
|
|
108
|
+
const combined = collectResolvedDeps(meta, propsByPom, {});
|
|
109
|
+
const npm = collectNpmDeps(FIX, {});
|
|
110
|
+
for (const [k, v] of npm) combined.set(k, v);
|
|
111
|
+
|
|
112
|
+
// Tiny stub CVE index. Only Maven-relevant — npm should be silently skipped by matchDepsAgainstCves.
|
|
113
|
+
const idx = {
|
|
114
|
+
byPackageName: {
|
|
115
|
+
"org.apache.logging.log4j:log4j-core": [{
|
|
116
|
+
id: "CVE-2021-44228",
|
|
117
|
+
severity: "CRITICAL",
|
|
118
|
+
score: 10.0,
|
|
119
|
+
description: "Log4Shell",
|
|
120
|
+
fixVersion: "2.15.0",
|
|
121
|
+
ranges: [{ status: "affected", version: "2.0.0", lessThan: "2.15.0", versionType: "maven" }],
|
|
122
|
+
vendor: "apache",
|
|
123
|
+
product: "log4j-core",
|
|
124
|
+
}],
|
|
125
|
+
},
|
|
126
|
+
byProduct: {},
|
|
127
|
+
};
|
|
128
|
+
const matches = matchDepsAgainstCves(combined, idx);
|
|
129
|
+
assert.ok(matches.length >= 1);
|
|
130
|
+
const log4shell = matches.find(m => m.cve.id === "CVE-2021-44228");
|
|
131
|
+
assert.ok(log4shell, "log4-core 2.14.0 should match CVE-2021-44228");
|
|
132
|
+
});
|
package/test/npm.test.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const { test } = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const {
|
|
5
|
+
parsePackageJson,
|
|
6
|
+
parsePackageLock,
|
|
7
|
+
parseYarnLockV1,
|
|
8
|
+
findJsManifests,
|
|
9
|
+
} = require("../lib/npm/parse");
|
|
10
|
+
const { collectNpmDeps } = require("../lib/npm/collect");
|
|
11
|
+
|
|
12
|
+
const FIX = path.join(__dirname, "fixtures", "monorepo-mixed");
|
|
13
|
+
|
|
14
|
+
test("parsePackageJson extracts dependencies + dev + peer scopes", () => {
|
|
15
|
+
const res = parsePackageJson(path.join(FIX, "packages", "web-app", "package.json"));
|
|
16
|
+
assert.equal(res.packageName, "@acme/web-app");
|
|
17
|
+
assert.equal(res.packageVersion, "1.0.0");
|
|
18
|
+
const byName = Object.fromEntries(res.deps.map(d => [d.name, d]));
|
|
19
|
+
assert.equal(byName.axios.scope, "prod");
|
|
20
|
+
assert.equal(byName.jest.scope, "dev");
|
|
21
|
+
assert.equal(byName.react.scope, "peer");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("parsePackageLock v3 enumerates direct + transitive deps with correct scopes", () => {
|
|
25
|
+
const res = parsePackageLock(path.join(FIX, "packages", "web-app", "package-lock.json"));
|
|
26
|
+
assert.equal(res.lockfileVersion, 3);
|
|
27
|
+
const byName = Object.fromEntries(res.deps.map(d => [d.name, d]));
|
|
28
|
+
// direct prod
|
|
29
|
+
assert.equal(byName.axios.scope, "prod");
|
|
30
|
+
assert.equal(byName.axios.version, "0.21.0");
|
|
31
|
+
// transitive (depth via nested node_modules) — flat in v3, but here axios's "follow-redirects" lives at the top, depth 0
|
|
32
|
+
assert.equal(byName["follow-redirects"].version, "1.13.0");
|
|
33
|
+
// dev
|
|
34
|
+
assert.equal(byName.jest.scope, "dev");
|
|
35
|
+
assert.equal(byName.eslint.scope, "dev");
|
|
36
|
+
// peer
|
|
37
|
+
assert.equal(byName.react.scope, "peer");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("parseYarnLockV1 picks up versions and dedupes by (name, version)", () => {
|
|
41
|
+
const res = parseYarnLockV1(path.join(FIX, "packages", "cli", "yarn.lock"));
|
|
42
|
+
assert.equal(res.lockfileVersion, 1);
|
|
43
|
+
const byName = Object.fromEntries(res.deps.map(d => [d.name, d]));
|
|
44
|
+
assert.equal(byName.chalk.version, "4.1.2");
|
|
45
|
+
assert.equal(byName["ansi-styles"].version, "4.3.0");
|
|
46
|
+
assert.equal(byName["@acme/private-utils"].version, "1.0.0");
|
|
47
|
+
assert.equal(byName.mocha.version, "9.0.0");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("findJsManifests discovers both packages, skips node_modules and Maven dirs", () => {
|
|
51
|
+
const groups = findJsManifests(FIX);
|
|
52
|
+
const dirs = groups.map(g => path.relative(FIX, g.dir)).sort();
|
|
53
|
+
assert.ok(dirs.includes(path.join("packages", "web-app")));
|
|
54
|
+
assert.ok(dirs.includes(path.join("packages", "cli")));
|
|
55
|
+
// No Maven dir should appear
|
|
56
|
+
assert.ok(!dirs.some(d => d.startsWith("services" + path.sep)));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("collectNpmDeps merges both packages and namespaces keys with 'npm:'", () => {
|
|
60
|
+
const map = collectNpmDeps(FIX, { verbose: false });
|
|
61
|
+
assert.ok(map.has("npm:axios"));
|
|
62
|
+
assert.ok(map.has("npm:lodash"));
|
|
63
|
+
assert.ok(map.has("npm:chalk"));
|
|
64
|
+
assert.ok(map.has("npm:@acme/private-utils"));
|
|
65
|
+
const axios = map.get("npm:axios");
|
|
66
|
+
assert.equal(axios.ecosystem, "npm");
|
|
67
|
+
assert.equal(axios.artifactId, "axios");
|
|
68
|
+
assert.equal(axios.groupId, "");
|
|
69
|
+
assert.equal(axios.version, "0.21.0");
|
|
70
|
+
assert.equal(axios.scope, "prod");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("collectNpmDeps --ignore-test skips devDependencies (jest, eslint, mocha)", () => {
|
|
74
|
+
const map = collectNpmDeps(FIX, { ignoreTest: true, verbose: false });
|
|
75
|
+
assert.equal(map.has("npm:jest"), false);
|
|
76
|
+
assert.equal(map.has("npm:eslint"), false);
|
|
77
|
+
assert.equal(map.has("npm:mocha"), false);
|
|
78
|
+
// prod deps still present
|
|
79
|
+
assert.ok(map.has("npm:axios"));
|
|
80
|
+
assert.ok(map.has("npm:chalk"));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("collectNpmDeps deps2Exclude regex strips @acme/private-utils", () => {
|
|
84
|
+
const map = collectNpmDeps(FIX, { deps2Exclude: /^@acme\//, verbose: false });
|
|
85
|
+
assert.equal(map.has("npm:@acme/private-utils"), false);
|
|
86
|
+
// public deps still present
|
|
87
|
+
assert.ok(map.has("npm:axios"));
|
|
88
|
+
assert.ok(map.has("npm:chalk"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("collectNpmDeps prefers lockfile (resolved) version over package.json range", () => {
|
|
92
|
+
const map = collectNpmDeps(FIX, { verbose: false });
|
|
93
|
+
// In web-app, package.json pins "0.21.0" and lockfile says "0.21.0" — verify
|
|
94
|
+
// concrete resolved version is the one kept.
|
|
95
|
+
const axios = map.get("npm:axios");
|
|
96
|
+
assert.equal(/^\d/.test(axios.version), true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("collectNpmDeps skips package.json when a lockfile is present in the same dir", () => {
|
|
100
|
+
// web-app has both package.json and package-lock.json. Only the lockfile
|
|
101
|
+
// should source dep entries; the package.json (which carries ranges like
|
|
102
|
+
// "^1.0.0" we can't query OSV with) must be ignored as a source.
|
|
103
|
+
const map = collectNpmDeps(FIX, { verbose: false });
|
|
104
|
+
const axios = map.get("npm:axios");
|
|
105
|
+
assert.ok(axios.lockType?.startsWith("package-lock-v"), `axios should come from lockfile, got lockType=${axios.lockType}`);
|
|
106
|
+
// And the only manifestPaths recorded for axios should be the lockfile
|
|
107
|
+
for (const p of axios.manifestPaths) {
|
|
108
|
+
assert.ok(p.endsWith("package-lock.json") || p.endsWith("yarn.lock"),
|
|
109
|
+
`expected only lockfile manifestPaths for axios, got ${p}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("collectNpmDeps emits a 'no-lockfile' warning for package.json without sibling lock", () => {
|
|
114
|
+
const map = collectNpmDeps(FIX, { verbose: false });
|
|
115
|
+
assert.ok(Array.isArray(map.warnings), "warnings array should be present");
|
|
116
|
+
const w = map.warnings.find(x => x.type === "no-lockfile" && x.manifestPath.includes("no-lock"));
|
|
117
|
+
assert.ok(w, "expected a no-lockfile warning for packages/no-lock/package.json");
|
|
118
|
+
// And no left-pad / no @acme/no-lock dep should leak into the Map (range-only)
|
|
119
|
+
assert.equal(map.has("npm:left-pad"), false, "left-pad ^1.0.0 must not be collected (unresolved range)");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("parsePackageLock tags flattened-transitive entries as scope='transitive'", () => {
|
|
123
|
+
// follow-redirects is pulled in by axios but is not in the root's direct
|
|
124
|
+
// dependency lists — even though npm v3 places it at depth 0 in node_modules,
|
|
125
|
+
// it must be reported as transitive.
|
|
126
|
+
const res = parsePackageLock(path.join(FIX, "packages", "web-app", "package-lock.json"));
|
|
127
|
+
const byName = Object.fromEntries(res.deps.map(d => [d.name, d]));
|
|
128
|
+
assert.equal(byName["follow-redirects"].scope, "transitive");
|
|
129
|
+
assert.equal(byName.qs.scope, "transitive");
|
|
130
|
+
// direct deps stay direct
|
|
131
|
+
assert.equal(byName.axios.scope, "prod");
|
|
132
|
+
assert.equal(byName.express.scope, "prod");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("collectNpmDeps sets ecosystemType=npm for package-lock and yarn for yarn.lock", () => {
|
|
136
|
+
const map = collectNpmDeps(FIX, { verbose: false });
|
|
137
|
+
assert.equal(map.get("npm:axios").ecosystemType, "npm"); // from package-lock.json
|
|
138
|
+
assert.equal(map.get("npm:chalk").ecosystemType, "yarn"); // from yarn.lock
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("collectNpmDeps captures peerDependencies (react@17 in web-app)", () => {
|
|
142
|
+
const map = collectNpmDeps(FIX, { verbose: false });
|
|
143
|
+
const react = map.get("npm:react");
|
|
144
|
+
assert.ok(react, "react peer dep should be present");
|
|
145
|
+
assert.equal(react.scope, "peer");
|
|
146
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const { test } = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { checkObsoleteDeps, checkObsolete, findEolProduct, KNOWN_OBSOLETE } = require("../lib/outdated");
|
|
4
|
+
|
|
5
|
+
test("known-obsolete.json contains the obvious historical hazards", () => {
|
|
6
|
+
assert.ok(KNOWN_OBSOLETE["log4j:log4j"], "log4j 1.x must be flagged");
|
|
7
|
+
assert.ok(KNOWN_OBSOLETE["commons-logging:commons-logging"]);
|
|
8
|
+
assert.ok(KNOWN_OBSOLETE["org.codehaus.jackson:jackson-databind"] || KNOWN_OBSOLETE["org.codehaus.jackson:jackson-mapper-asl"]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("checkObsoleteDeps flags log4j 1.x and jackson 1.x", () => {
|
|
12
|
+
const deps = new Map([
|
|
13
|
+
["log4j:log4j", { groupId: "log4j", artifactId: "log4j", version: "1.2.17" }],
|
|
14
|
+
["org.codehaus.jackson:jackson-mapper-asl", { groupId: "org.codehaus.jackson", artifactId: "jackson-mapper-asl", version: "1.9.13" }],
|
|
15
|
+
["com.fasterxml.jackson.core:jackson-databind", { groupId: "com.fasterxml.jackson.core", artifactId: "jackson-databind", version: "2.16.0" }],
|
|
16
|
+
]);
|
|
17
|
+
const out = checkObsoleteDeps(deps);
|
|
18
|
+
const ids = out.map(o => `${o.dep.groupId}:${o.dep.artifactId}`);
|
|
19
|
+
assert.ok(ids.includes("log4j:log4j"));
|
|
20
|
+
assert.ok(ids.includes("org.codehaus.jackson:jackson-mapper-asl"));
|
|
21
|
+
assert.ok(!ids.includes("com.fasterxml.jackson.core:jackson-databind"));
|
|
22
|
+
const log4j = out.find(o => o.dep.artifactId === "log4j");
|
|
23
|
+
assert.equal(log4j.severity, "CRITICAL");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("checkObsoleteDeps deduplicates by g:a", () => {
|
|
27
|
+
const deps = new Map([
|
|
28
|
+
["log4j:log4j", { groupId: "log4j", artifactId: "log4j", version: "1.2.17" }],
|
|
29
|
+
]);
|
|
30
|
+
// Call twice — should still report once
|
|
31
|
+
const out1 = checkObsoleteDeps(deps);
|
|
32
|
+
const out2 = checkObsoleteDeps(deps);
|
|
33
|
+
assert.equal(out1.length, 1);
|
|
34
|
+
assert.equal(out2.length, 1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("checkObsolete single-dep returns details or null", () => {
|
|
38
|
+
const o = checkObsolete({ groupId: "log4j", artifactId: "log4j", version: "1.2" });
|
|
39
|
+
assert.ok(o);
|
|
40
|
+
assert.equal(o.severity, "CRITICAL");
|
|
41
|
+
assert.equal(checkObsolete({ groupId: "com.fasterxml.jackson.core", artifactId: "jackson-databind" }), null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("findEolProduct matches Spring Boot by exact coord and by prefix", () => {
|
|
45
|
+
const sb = findEolProduct({ groupId: "org.springframework.boot", artifactId: "spring-boot-starter-parent" });
|
|
46
|
+
assert.equal(sb.product, "spring-boot");
|
|
47
|
+
|
|
48
|
+
const sbcustom = findEolProduct({ groupId: "org.springframework.boot", artifactId: "spring-boot-starter-anything" });
|
|
49
|
+
assert.equal(sbcustom.product, "spring-boot", "prefix-only mapping must still match");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("findEolProduct picks longest prefix match", () => {
|
|
53
|
+
const sec = findEolProduct({ groupId: "org.springframework.security", artifactId: "made-up" });
|
|
54
|
+
assert.equal(sec.product, "spring-framework");
|
|
55
|
+
assert.equal(sec.label, "Spring Security");
|
|
56
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const { test } = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { parseSnykResults, parseSnykStdout, mergeWithFadResults } = require("../lib/snyk");
|
|
4
|
+
|
|
5
|
+
const snykSample = {
|
|
6
|
+
vulnerabilities: [
|
|
7
|
+
{
|
|
8
|
+
id: "SNYK-JAVA-LOG4J-2314720",
|
|
9
|
+
packageName: "org.apache.logging.log4j:log4j-core",
|
|
10
|
+
version: "2.14.0",
|
|
11
|
+
severity: "critical",
|
|
12
|
+
cvssScore: 10,
|
|
13
|
+
title: "Remote Code Execution",
|
|
14
|
+
fixedIn: ["2.15.0", "2.16.0"],
|
|
15
|
+
identifiers: { CVE: ["CVE-2021-44228"] },
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "SNYK-JAVA-JACKSON-2421244",
|
|
19
|
+
packageName: "com.fasterxml.jackson.core:jackson-databind",
|
|
20
|
+
version: "2.13.0",
|
|
21
|
+
severity: "high",
|
|
22
|
+
cvssScore: 7.5,
|
|
23
|
+
title: "Denial of Service",
|
|
24
|
+
fixedIn: ["2.13.5"],
|
|
25
|
+
identifiers: { CVE: ["CVE-2022-42003"] },
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
test("parseSnykStdout accepts a JSON object or array", () => {
|
|
31
|
+
assert.deepEqual(parseSnykStdout(""), []);
|
|
32
|
+
const arr = parseSnykStdout(JSON.stringify([snykSample]));
|
|
33
|
+
assert.equal(arr.length, 1);
|
|
34
|
+
const obj = parseSnykStdout(JSON.stringify(snykSample));
|
|
35
|
+
assert.equal(obj.length, 1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("parseSnykResults normalises to fad-check match shape", () => {
|
|
39
|
+
const out = parseSnykResults(snykSample);
|
|
40
|
+
assert.equal(out.length, 2);
|
|
41
|
+
assert.equal(out[0].dep.groupId, "org.apache.logging.log4j");
|
|
42
|
+
assert.equal(out[0].dep.artifactId, "log4j-core");
|
|
43
|
+
assert.equal(out[0].cve.id, "CVE-2021-44228");
|
|
44
|
+
assert.equal(out[0].cve.severity, "CRITICAL");
|
|
45
|
+
assert.equal(out[0].cve.fixVersion, "2.15.0");
|
|
46
|
+
assert.equal(out[0].source, "snyk");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("mergeWithFadResults tags overlap as 'both' and keeps Snyk-only as 'snyk'", () => {
|
|
50
|
+
const fadMatches = [
|
|
51
|
+
{
|
|
52
|
+
dep: { groupId: "org.apache.logging.log4j", artifactId: "log4j-core", version: "2.14.0" },
|
|
53
|
+
cve: { id: "CVE-2021-44228", severity: "CRITICAL" },
|
|
54
|
+
confidence: "exact",
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
const snykMatches = parseSnykResults(snykSample);
|
|
58
|
+
const merged = mergeWithFadResults(fadMatches, snykMatches);
|
|
59
|
+
assert.equal(merged.length, 2);
|
|
60
|
+
const log4j = merged.find(m => m.cve.id === "CVE-2021-44228");
|
|
61
|
+
assert.equal(log4j.source, "both");
|
|
62
|
+
const jackson = merged.find(m => m.cve.id === "CVE-2022-42003");
|
|
63
|
+
assert.equal(jackson.source, "snyk");
|
|
64
|
+
});
|