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.
Files changed (67) hide show
  1. package/CLAUDE.md +126 -0
  2. package/README.md +279 -0
  3. package/completions/fad-check.bash +23 -0
  4. package/completions/fad-check.zsh +27 -0
  5. package/data/cpe-coord-map.json +112 -0
  6. package/data/eol-mapping.json +34 -0
  7. package/data/known-obsolete.json +142 -0
  8. package/data/known-public-namespaces.json +79 -0
  9. package/docs/ARCHITECTURE.md +152 -0
  10. package/docs/USAGE.md +179 -0
  11. package/fad-check.js +665 -0
  12. package/lib/cache-archive.js +107 -0
  13. package/lib/config.js +87 -0
  14. package/lib/core.js +317 -0
  15. package/lib/cpe.js +287 -0
  16. package/lib/cve-download.js +369 -0
  17. package/lib/cve-match.js +228 -0
  18. package/lib/cve-report.js +1455 -0
  19. package/lib/maven-repo.js +134 -0
  20. package/lib/maven-version.js +153 -0
  21. package/lib/npm/collect.js +224 -0
  22. package/lib/npm/parse.js +291 -0
  23. package/lib/nvd.js +239 -0
  24. package/lib/osv.js +298 -0
  25. package/lib/outdated.js +265 -0
  26. package/lib/retire.js +211 -0
  27. package/lib/scan-completeness.js +67 -0
  28. package/lib/snyk.js +127 -0
  29. package/lib/transitive.js +410 -0
  30. package/package.json +35 -0
  31. package/test/core.test.js +153 -0
  32. package/test/cpe.test.js +148 -0
  33. package/test/cve-download.test.js +39 -0
  34. package/test/cve-match.test.js +108 -0
  35. package/test/cve-report.test.js +79 -0
  36. package/test/fixtures/complex-enterprise/api/pom.xml +32 -0
  37. package/test/fixtures/complex-enterprise/build/pom.xml +46 -0
  38. package/test/fixtures/complex-enterprise/dao/pom.xml +37 -0
  39. package/test/fixtures/complex-enterprise/pom.xml +66 -0
  40. package/test/fixtures/complex-enterprise/web/pom.xml +77 -0
  41. package/test/fixtures/cve-samples/cve-non-java.json +19 -0
  42. package/test/fixtures/cve-samples/cve-product-only.json +31 -0
  43. package/test/fixtures/cve-samples/cve-with-packagename.json +37 -0
  44. package/test/fixtures/cve-samples/nvd-log4shell.json +40 -0
  45. package/test/fixtures/cve-samples/nvd-npm-lodash.json +22 -0
  46. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +26 -0
  47. package/test/fixtures/monorepo-mixed/packages/cli/package.json +14 -0
  48. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +41 -0
  49. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +9 -0
  50. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +71 -0
  51. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +17 -0
  52. package/test/fixtures/monorepo-mixed/pom.xml +29 -0
  53. package/test/fixtures/monorepo-mixed/services/api/pom.xml +27 -0
  54. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +28 -0
  55. package/test/fixtures/private-lib-detection/core/pom.xml +26 -0
  56. package/test/fixtures/private-lib-detection/plugin/pom.xml +23 -0
  57. package/test/fixtures/private-lib-detection/pom.xml +35 -0
  58. package/test/fixtures/simple/app/pom.xml +28 -0
  59. package/test/fixtures/simple/lib/pom.xml +18 -0
  60. package/test/fixtures/simple/pom.xml +24 -0
  61. package/test/maven-repo.test.js +111 -0
  62. package/test/maven-version.test.js +48 -0
  63. package/test/monorepo.test.js +132 -0
  64. package/test/npm.test.js +146 -0
  65. package/test/outdated.test.js +56 -0
  66. package/test/snyk.test.js +64 -0
  67. 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
+ });
@@ -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
+ });