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,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/maven-repo.js — fan-out HTTP fetcher across the user's configured
|
|
3
|
+
* Maven repositories with Maven Central as a final fallback.
|
|
4
|
+
*
|
|
5
|
+
* Use cases:
|
|
6
|
+
* - Fetching a transitive POM (lib/transitive.js)
|
|
7
|
+
* - Checking whether an artifact exists at all (HEAD)
|
|
8
|
+
* - Reading <maven-metadata.xml> for latest-version discovery
|
|
9
|
+
* (lib/outdated.js)
|
|
10
|
+
*
|
|
11
|
+
* Repository entry shape (from ~/.fad-check/config.json or CLI):
|
|
12
|
+
* { name?, url, auth? } auth = "user:pass" (we wrap as Basic <base64>)
|
|
13
|
+
*
|
|
14
|
+
* URL convention: each repo URL must end at the directory under which Maven
|
|
15
|
+
* artifacts are laid out the standard way:
|
|
16
|
+
* <repo-url>/<groupId-with-/>/<artifactId>/<version>/<artifactId>-<version>.pom
|
|
17
|
+
*
|
|
18
|
+
* The first 2xx wins. Misses are silently aggregated; the caller decides
|
|
19
|
+
* what to do with "not found anywhere".
|
|
20
|
+
*/
|
|
21
|
+
const MAVEN_CENTRAL = { name: "central", url: "https://repo1.maven.org/maven2/" };
|
|
22
|
+
|
|
23
|
+
function normalise(url) {
|
|
24
|
+
if (!url) return url;
|
|
25
|
+
return url.endsWith("/") ? url : url + "/";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse user:pass embedded in a URL (e.g. https://alice:s3cr3t@nexus.acme/...)
|
|
30
|
+
* Returns { url, auth } where auth is "user:pass" stripped of the URL part.
|
|
31
|
+
*/
|
|
32
|
+
function splitUrlAuth(url) {
|
|
33
|
+
if (!url) return { url, auth: null };
|
|
34
|
+
try {
|
|
35
|
+
const u = new URL(url);
|
|
36
|
+
if (u.username || u.password) {
|
|
37
|
+
const auth = decodeURIComponent(u.username) + ":" + decodeURIComponent(u.password);
|
|
38
|
+
u.username = ""; u.password = "";
|
|
39
|
+
return { url: u.toString(), auth };
|
|
40
|
+
}
|
|
41
|
+
} catch { /* not a URL — return as-is */ }
|
|
42
|
+
return { url, auth: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the effective repository list: user-configured + extras (from --repo
|
|
47
|
+
* CLI) + Maven Central as final fallback. Dedupes by URL.
|
|
48
|
+
*/
|
|
49
|
+
function buildRepoList(userRepos, extraRepos = []) {
|
|
50
|
+
const out = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
const push = r => {
|
|
53
|
+
if (!r?.url) return;
|
|
54
|
+
const { url, auth } = splitUrlAuth(normalise(r.url));
|
|
55
|
+
if (seen.has(url)) return;
|
|
56
|
+
seen.add(url);
|
|
57
|
+
out.push({ name: r.name || url, url, auth: r.auth || auth || null });
|
|
58
|
+
};
|
|
59
|
+
for (const r of userRepos || []) push(r);
|
|
60
|
+
for (const r of extraRepos || []) push(r);
|
|
61
|
+
push(MAVEN_CENTRAL);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function authHeader(auth) {
|
|
66
|
+
if (!auth) return null;
|
|
67
|
+
return "Basic " + Buffer.from(auth).toString("base64");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Try fetching `pathSuffix` (relative to each repo URL) from every repo
|
|
72
|
+
* in order. Returns the first 2xx response as { repo, response, body? }.
|
|
73
|
+
*
|
|
74
|
+
* opts:
|
|
75
|
+
* method "GET" (default) | "HEAD"
|
|
76
|
+
* fetcher custom fetch (for tests)
|
|
77
|
+
* readBody read response.text() into body (default false to save mem
|
|
78
|
+
* on HEAD calls; transitive.js sets true for POMs)
|
|
79
|
+
* userAgent default "fad-check-maven-repo"
|
|
80
|
+
* onMiss callback(repo, status) for telemetry (verbose mode)
|
|
81
|
+
*/
|
|
82
|
+
async function tryRepos(repos, pathSuffix, opts = {}) {
|
|
83
|
+
const { method = "GET", fetcher = globalThis.fetch, readBody = false, userAgent = "fad-check-maven-repo", onMiss } = opts;
|
|
84
|
+
for (const repo of repos) {
|
|
85
|
+
const url = repo.url + pathSuffix.replace(/^\//, "");
|
|
86
|
+
const headers = { "User-Agent": userAgent };
|
|
87
|
+
const ah = authHeader(repo.auth);
|
|
88
|
+
if (ah) headers.Authorization = ah;
|
|
89
|
+
let r;
|
|
90
|
+
try {
|
|
91
|
+
r = await fetcher(url, { method, headers });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (onMiss) onMiss(repo, `network: ${err.message}`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (r.ok) {
|
|
97
|
+
let body = null;
|
|
98
|
+
if (readBody && method !== "HEAD") {
|
|
99
|
+
try { body = await r.text(); } catch { /* ignore body read fail */ }
|
|
100
|
+
}
|
|
101
|
+
return { repo, response: r, body, url };
|
|
102
|
+
}
|
|
103
|
+
if (onMiss) onMiss(repo, `HTTP ${r.status}`);
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Convenience: HEAD an artifact (any of the listed repos) to check existence. */
|
|
109
|
+
function existsInAny(repos, pathSuffix, opts = {}) {
|
|
110
|
+
return tryRepos(repos, pathSuffix, { ...opts, method: "HEAD" });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Convenience: GET a POM (text/xml) — sets readBody=true. */
|
|
114
|
+
function fetchPomFromRepos(repos, groupId, artifactId, version, opts = {}) {
|
|
115
|
+
const p = `${groupId.replace(/\./g, "/")}/${artifactId}/${version}/${artifactId}-${version}.pom`;
|
|
116
|
+
return tryRepos(repos, p, { ...opts, method: "GET", readBody: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Convenience: GET maven-metadata.xml (for latest-version discovery). */
|
|
120
|
+
function fetchMavenMetadata(repos, groupId, artifactId, opts = {}) {
|
|
121
|
+
const p = `${groupId.replace(/\./g, "/")}/${artifactId}/maven-metadata.xml`;
|
|
122
|
+
return tryRepos(repos, p, { ...opts, method: "GET", readBody: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
MAVEN_CENTRAL,
|
|
127
|
+
buildRepoList,
|
|
128
|
+
tryRepos,
|
|
129
|
+
existsInAny,
|
|
130
|
+
fetchPomFromRepos,
|
|
131
|
+
fetchMavenMetadata,
|
|
132
|
+
splitUrlAuth,
|
|
133
|
+
authHeader,
|
|
134
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/maven-version.js — Maven-flavoured version parsing and comparison.
|
|
3
|
+
*
|
|
4
|
+
* Maven version ordering rules (approximation of Apache Maven's
|
|
5
|
+
* ComparableVersion):
|
|
6
|
+
* - Versions are split on `.` and `-` into segments.
|
|
7
|
+
* - Numeric segments compare numerically.
|
|
8
|
+
* - String segments compare via a qualifier ordering:
|
|
9
|
+
* alpha < beta < milestone < rc < snapshot < "" (release) < sp
|
|
10
|
+
* - Trailing zeros are insignificant: 1.0 == 1.0.0 == 1.
|
|
11
|
+
* - Known release qualifiers (final, release, ga) are treated as "".
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Lower number == lower precedence
|
|
15
|
+
const QUALIFIER_ORDER = {
|
|
16
|
+
"alpha": 1, "a": 1,
|
|
17
|
+
"beta": 2, "b": 2,
|
|
18
|
+
"milestone": 3, "m": 3,
|
|
19
|
+
"rc": 4, "cr": 4,
|
|
20
|
+
"snapshot": 5,
|
|
21
|
+
"": 6, "ga": 6, "final": 6, "release": 6,
|
|
22
|
+
"sp": 7,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function parseMavenVersion(versionStr) {
|
|
26
|
+
if (versionStr == null) return { original: "", segments: [] };
|
|
27
|
+
const original = String(versionStr).trim();
|
|
28
|
+
if (!original) return { original: "", segments: [] };
|
|
29
|
+
|
|
30
|
+
// Split on `.` and `-`, lowercase string segments
|
|
31
|
+
const raw = original.toLowerCase().split(/[.\-]/);
|
|
32
|
+
const segments = raw.map(s => {
|
|
33
|
+
if (/^\d+$/.test(s)) return { kind: "num", value: parseInt(s, 10) };
|
|
34
|
+
// Embedded numbers (e.g. "rc1" → ["rc", 1])
|
|
35
|
+
const m = s.match(/^([a-z]+)(\d+)$/);
|
|
36
|
+
if (m) return { kind: "qual+num", qual: m[1], num: parseInt(m[2], 10) };
|
|
37
|
+
return { kind: "str", value: s };
|
|
38
|
+
});
|
|
39
|
+
return { original, segments };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function qualifierRank(q) {
|
|
43
|
+
if (q == null) return QUALIFIER_ORDER[""];
|
|
44
|
+
const r = QUALIFIER_ORDER[q.toLowerCase()];
|
|
45
|
+
return r != null ? r : QUALIFIER_ORDER[""] - 0.5; // unknown qualifier sits just below release
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function qualOf(seg) {
|
|
49
|
+
if (!seg) return null;
|
|
50
|
+
if (seg.kind === "str") return seg.value;
|
|
51
|
+
if (seg.kind === "qual+num") return seg.qual;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cmpSegments(a, b) {
|
|
56
|
+
// a or b may be missing — treat as numeric 0 (trailing zeros are insignificant)
|
|
57
|
+
if (!a) {
|
|
58
|
+
if (b.kind === "num") return b.value === 0 ? 0 : -1;
|
|
59
|
+
// b is a qualifier (str or qual+num) — pre-release < release
|
|
60
|
+
return qualifierRank("") - qualifierRank(qualOf(b));
|
|
61
|
+
}
|
|
62
|
+
if (!b) {
|
|
63
|
+
if (a.kind === "num") return a.value === 0 ? 0 : 1;
|
|
64
|
+
return qualifierRank(qualOf(a)) - qualifierRank("");
|
|
65
|
+
}
|
|
66
|
+
if (a.kind === "num" && b.kind === "num") return a.value - b.value;
|
|
67
|
+
if (a.kind === "num") {
|
|
68
|
+
// number vs qualifier — numbers are "newer" than pre-release qualifiers
|
|
69
|
+
const r = qualifierRank(b.value);
|
|
70
|
+
return r < QUALIFIER_ORDER[""] ? 1 : -1;
|
|
71
|
+
}
|
|
72
|
+
if (b.kind === "num") {
|
|
73
|
+
const r = qualifierRank(a.value);
|
|
74
|
+
return r < QUALIFIER_ORDER[""] ? -1 : 1;
|
|
75
|
+
}
|
|
76
|
+
if (a.kind === "qual+num" && b.kind === "qual+num") {
|
|
77
|
+
const d = qualifierRank(a.qual) - qualifierRank(b.qual);
|
|
78
|
+
return d !== 0 ? d : a.num - b.num;
|
|
79
|
+
}
|
|
80
|
+
if (a.kind === "qual+num") return qualifierRank(a.qual) - qualifierRank(b.value);
|
|
81
|
+
if (b.kind === "qual+num") return qualifierRank(a.value) - qualifierRank(b.qual);
|
|
82
|
+
return qualifierRank(a.value) - qualifierRank(b.value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function compareMavenVersions(aStr, bStr) {
|
|
86
|
+
const a = parseMavenVersion(aStr).segments;
|
|
87
|
+
const b = parseMavenVersion(bStr).segments;
|
|
88
|
+
const n = Math.max(a.length, b.length);
|
|
89
|
+
for (let i = 0; i < n; i++) {
|
|
90
|
+
const c = cmpSegments(a[i], b[i]);
|
|
91
|
+
if (c !== 0) return c < 0 ? -1 : 1;
|
|
92
|
+
}
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check whether a dependency version falls within a CVE-specified range.
|
|
98
|
+
* spec shape: { version, status, lessThan, lessThanOrEqual, versionType }
|
|
99
|
+
* Returns true if depVersion is affected.
|
|
100
|
+
*/
|
|
101
|
+
function isVersionAffected(depVersion, spec) {
|
|
102
|
+
if (!spec) return false;
|
|
103
|
+
if (spec.status && spec.status !== "affected") return false;
|
|
104
|
+
|
|
105
|
+
const dep = parseMavenVersion(depVersion);
|
|
106
|
+
if (!dep.segments.length) return false;
|
|
107
|
+
|
|
108
|
+
// Lower bound (inclusive)
|
|
109
|
+
if (spec.version && spec.version !== "0" && spec.version !== "*") {
|
|
110
|
+
if (compareMavenVersions(depVersion, spec.version) < 0) return false;
|
|
111
|
+
}
|
|
112
|
+
// Upper bound exclusive
|
|
113
|
+
if (spec.lessThan) {
|
|
114
|
+
if (compareMavenVersions(depVersion, spec.lessThan) >= 0) return false;
|
|
115
|
+
}
|
|
116
|
+
// Upper bound inclusive
|
|
117
|
+
if (spec.lessThanOrEqual) {
|
|
118
|
+
if (compareMavenVersions(depVersion, spec.lessThanOrEqual) > 0) return false;
|
|
119
|
+
}
|
|
120
|
+
// Exact match with no bounds — only affected if equal
|
|
121
|
+
if (!spec.lessThan && !spec.lessThanOrEqual && spec.version && spec.version !== "0" && spec.version !== "*") {
|
|
122
|
+
if (compareMavenVersions(depVersion, spec.version) !== 0) return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse a Maven version range expression like "[1.0,2.0)", "(,1.5]", "1.2.3".
|
|
129
|
+
* Returns { lower, lowerInclusive, upper, upperInclusive, exact } or null.
|
|
130
|
+
*/
|
|
131
|
+
function parseRange(rangeStr) {
|
|
132
|
+
if (rangeStr == null) return null;
|
|
133
|
+
const s = String(rangeStr).trim();
|
|
134
|
+
if (!s) return null;
|
|
135
|
+
if (!/^[\[\(]/.test(s)) return { exact: s };
|
|
136
|
+
const open = s[0];
|
|
137
|
+
const close = s[s.length - 1];
|
|
138
|
+
const inner = s.slice(1, -1);
|
|
139
|
+
const [lo, hi] = inner.split(",").map(p => p.trim());
|
|
140
|
+
return {
|
|
141
|
+
lower: lo || null,
|
|
142
|
+
lowerInclusive: open === "[",
|
|
143
|
+
upper: hi || null,
|
|
144
|
+
upperInclusive: close === "]",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
parseMavenVersion,
|
|
150
|
+
compareMavenVersions,
|
|
151
|
+
isVersionAffected,
|
|
152
|
+
parseRange,
|
|
153
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/npm/collect.js — walk a JS project root, parse every manifest /
|
|
3
|
+
* lockfile, and produce a Map<key, depRecord> matching the shape that
|
|
4
|
+
* lib/cve-match.collectResolvedDeps produces for Maven.
|
|
5
|
+
*
|
|
6
|
+
* key: "npm:<name>" (deliberately namespaced so it never
|
|
7
|
+
* collides with Maven "g:a" keys)
|
|
8
|
+
* depRecord: { groupId: "", artifactId: name, version, scope, pomPaths,
|
|
9
|
+
* ecosystem: "npm", resolved, integrity, lockType,
|
|
10
|
+
* manifestPaths: [absolute paths of files mentioning it] }
|
|
11
|
+
*
|
|
12
|
+
* Conflict resolution between manifests for the same package:
|
|
13
|
+
* - lockfile entries WIN over package.json range specs (concrete version)
|
|
14
|
+
* - if two lockfiles disagree, keep the highest semver-comparable version
|
|
15
|
+
* - dev/optional/peer downgrade to prod if any manifest puts the dep in prod
|
|
16
|
+
*/
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const { parsePackageJson, parsePackageLock, parseYarnLockV1, findJsManifests } = require("./parse");
|
|
20
|
+
|
|
21
|
+
const SCOPE_RANK = { prod: 4, peer: 3, optional: 2, dev: 1, transitive: 0 };
|
|
22
|
+
|
|
23
|
+
function rankScope(s) { return SCOPE_RANK[s] || 0; }
|
|
24
|
+
|
|
25
|
+
function semverCompare(a, b) {
|
|
26
|
+
// Lightweight semver compare — good enough for "keep highest"; not
|
|
27
|
+
// canonical (doesn't fully implement build metadata ordering). For CVE
|
|
28
|
+
// matching we'll defer to ecosystem-aware comparators when needed.
|
|
29
|
+
const norm = v => String(v || "").replace(/^[v=]+/, "").split(/[-+]/)[0].split(".").map(n => parseInt(n, 10) || 0);
|
|
30
|
+
const ax = norm(a), bx = norm(b);
|
|
31
|
+
const n = Math.max(ax.length, bx.length);
|
|
32
|
+
for (let i = 0; i < n; i++) {
|
|
33
|
+
const d = (ax[i] || 0) - (bx[i] || 0);
|
|
34
|
+
if (d !== 0) return d > 0 ? 1 : -1;
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isResolvedVersion(v) {
|
|
40
|
+
// Reject ranges/specifiers like "^1.0.0", "~2.0", ">=1.0.0", "*", "latest", "git+..."
|
|
41
|
+
if (!v) return false;
|
|
42
|
+
if (/^[\^~>=<*]/.test(v)) return false;
|
|
43
|
+
if (/^(latest|next|workspace:|git\+|file:|link:|http)/i.test(v)) return false;
|
|
44
|
+
// Bare semver-ish strings only (digits and at most one dash for prerelease tag)
|
|
45
|
+
return /^\d+\.\d+(\.\d+)?([.\-+]\S+)?$/.test(v) || /^\d+$/.test(v);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function upsert(out, dep, manifestPath, lockType) {
|
|
49
|
+
const key = `npm:${dep.name}`;
|
|
50
|
+
const existing = out.get(key);
|
|
51
|
+
// ecosystemType narrows "ecosystem" to the tool that produced the lockfile:
|
|
52
|
+
// "npm" for package-lock.json
|
|
53
|
+
// "yarn" for yarn.lock
|
|
54
|
+
// Used by the report to split sections per tool.
|
|
55
|
+
const ecosystemType = (lockType || "").startsWith("package-lock") ? "npm"
|
|
56
|
+
: (lockType || "").startsWith("yarn") ? "yarn"
|
|
57
|
+
: "npm";
|
|
58
|
+
const record = {
|
|
59
|
+
groupId: "",
|
|
60
|
+
artifactId: dep.name,
|
|
61
|
+
version: dep.version || null,
|
|
62
|
+
scope: dep.scope || "prod",
|
|
63
|
+
isDev: !!dep.isDev,
|
|
64
|
+
pomPaths: [manifestPath],
|
|
65
|
+
manifestPaths: [manifestPath],
|
|
66
|
+
ecosystem: "npm",
|
|
67
|
+
ecosystemType,
|
|
68
|
+
lockType,
|
|
69
|
+
depth: dep.depth ?? null,
|
|
70
|
+
resolved: dep.resolved || null,
|
|
71
|
+
integrity: dep.integrity || null,
|
|
72
|
+
from: dep.from || null,
|
|
73
|
+
};
|
|
74
|
+
if (!existing) {
|
|
75
|
+
out.set(key, record);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Merge
|
|
79
|
+
if (!existing.manifestPaths.includes(manifestPath)) {
|
|
80
|
+
existing.manifestPaths.push(manifestPath);
|
|
81
|
+
existing.pomPaths.push(manifestPath);
|
|
82
|
+
}
|
|
83
|
+
// Prefer the resolved (lockfile) version over a range from package.json
|
|
84
|
+
const incoming = record.version;
|
|
85
|
+
const have = existing.version;
|
|
86
|
+
if (isResolvedVersion(incoming) && !isResolvedVersion(have)) {
|
|
87
|
+
existing.version = incoming;
|
|
88
|
+
} else if (isResolvedVersion(incoming) && isResolvedVersion(have)) {
|
|
89
|
+
if (semverCompare(incoming, have) > 0) existing.version = incoming;
|
|
90
|
+
} else if (!have && incoming) {
|
|
91
|
+
existing.version = incoming;
|
|
92
|
+
}
|
|
93
|
+
// Stronger scope wins (prod > peer > optional > dev)
|
|
94
|
+
if (rankScope(record.scope) > rankScope(existing.scope)) existing.scope = record.scope;
|
|
95
|
+
// A dep is "dev" overall only if every occurrence is dev. Any prod use → not dev.
|
|
96
|
+
if (!record.isDev) existing.isDev = false;
|
|
97
|
+
// First non-null resolved/integrity wins
|
|
98
|
+
if (!existing.resolved && record.resolved) existing.resolved = record.resolved;
|
|
99
|
+
if (!existing.integrity && record.integrity) existing.integrity = record.integrity;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Walk `rootDir`, parse every manifest, return { deps, warnings }.
|
|
104
|
+
*
|
|
105
|
+
* Returns:
|
|
106
|
+
* {
|
|
107
|
+
* deps: Map<key, depRecord>, // unchanged shape from before
|
|
108
|
+
* warnings: [ // surfaced into console + report
|
|
109
|
+
* { type: "no-lockfile", manifestPath, message }, ...
|
|
110
|
+
* ],
|
|
111
|
+
* }
|
|
112
|
+
*
|
|
113
|
+
* Lockfile policy: we ONLY collect deps from a lockfile (package-lock.json or
|
|
114
|
+
* yarn.lock). A package.json without a sibling lockfile is intentionally
|
|
115
|
+
* skipped — its values are ranges ("^1.0.0") that can't be queried against
|
|
116
|
+
* OSV and create false negatives. The caller is warned so they can either
|
|
117
|
+
* run `npm install` / `yarn install` to generate a lock, or accept the gap.
|
|
118
|
+
*
|
|
119
|
+
* opts:
|
|
120
|
+
* ignoreTest — skip dev / optional dependencies (mirrors Maven's --ignore-test)
|
|
121
|
+
* deps2Exclude — RegExp tested against the npm name (covers private @scope/* packages)
|
|
122
|
+
* verbose
|
|
123
|
+
*/
|
|
124
|
+
function collectNpmDeps(rootDir, opts = {}) {
|
|
125
|
+
const { ignoreTest, deps2Exclude, verbose } = opts;
|
|
126
|
+
const out = new Map();
|
|
127
|
+
const warnings = [];
|
|
128
|
+
const manifestGroups = findJsManifests(rootDir);
|
|
129
|
+
let parsedCount = 0;
|
|
130
|
+
|
|
131
|
+
for (const group of manifestGroups) {
|
|
132
|
+
const pj = group.packageJson ? safeParse(parsePackageJson, group.packageJson, verbose) : null;
|
|
133
|
+
const pl = group.packageLock ? safeParse(parsePackageLock, group.packageLock, verbose) : null;
|
|
134
|
+
const yl = group.yarnLock ? safeParse(parseYarnLockV1, group.yarnLock, verbose) : null;
|
|
135
|
+
const hasLock = !!(pl || yl);
|
|
136
|
+
|
|
137
|
+
// package.json without lockfile → warning, skip dep collection.
|
|
138
|
+
if (pj && !hasLock) {
|
|
139
|
+
warnings.push({
|
|
140
|
+
type: "no-lockfile",
|
|
141
|
+
manifestPath: group.packageJson,
|
|
142
|
+
packageName: pj.packageName || null,
|
|
143
|
+
message: `package.json without lockfile — skipped (run "npm install" or "yarn install" to generate one)`,
|
|
144
|
+
});
|
|
145
|
+
if (verbose) console.warn(`⚠️ ${group.packageJson}: no lockfile, skipped (${pj.deps.length} ranges in package.json)`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Yarn-Berry lockfile detected but unsupported → warning, skip.
|
|
149
|
+
if (yl?.unsupported === "yarn-berry") {
|
|
150
|
+
warnings.push({
|
|
151
|
+
type: "yarn-berry-unsupported",
|
|
152
|
+
manifestPath: group.yarnLock,
|
|
153
|
+
message: `yarn 2+/Berry lockfile not yet supported — skipped`,
|
|
154
|
+
});
|
|
155
|
+
if (verbose) console.warn(`⚠️ ${group.yarnLock}: yarn-berry not supported, skipped`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Used only as a source of scope info — never emits deps on its own.
|
|
159
|
+
const directScopes = pj ? new Map(pj.deps.map(d => [d.name, d.scope])) : null;
|
|
160
|
+
|
|
161
|
+
if (pl) {
|
|
162
|
+
parsedCount++;
|
|
163
|
+
for (const d of pl.deps) {
|
|
164
|
+
// In v1 npm dev/optional propagate; in v2/v3 the `dev` flag is reliable
|
|
165
|
+
const explicit = directScopes?.get(d.name);
|
|
166
|
+
const scope = explicit || d.scope || "prod";
|
|
167
|
+
if (ignoreTest && (scope === "dev" || scope === "optional")) continue;
|
|
168
|
+
if (deps2Exclude && deps2Exclude.test(d.name)) continue;
|
|
169
|
+
upsert(out, { ...d, scope }, group.packageLock, `package-lock-v${pl.lockfileVersion}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (yl) {
|
|
173
|
+
parsedCount++;
|
|
174
|
+
// yarn-berry warning already pushed above; only emit deps for v1
|
|
175
|
+
if (yl.unsupported !== "yarn-berry") {
|
|
176
|
+
for (const d of yl.deps) {
|
|
177
|
+
const explicit = directScopes?.get(d.name);
|
|
178
|
+
const scope = explicit || "prod";
|
|
179
|
+
if (ignoreTest && (scope === "dev" || scope === "optional")) continue;
|
|
180
|
+
if (deps2Exclude && deps2Exclude.test(d.name)) continue;
|
|
181
|
+
upsert(out, { ...d, scope }, group.yarnLock, "yarn-v1");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (verbose) console.log(`📦 npm/yarn: parsed ${parsedCount} manifests, ${out.size} unique packages${warnings.length ? `, ${warnings.length} warnings` : ""}`);
|
|
188
|
+
// Backward compat: the Map carries the warnings under a non-enumerable prop
|
|
189
|
+
// so existing callers that iterate `for (const [k,v] of map)` still work.
|
|
190
|
+
Object.defineProperty(out, "warnings", { value: warnings, enumerable: false });
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function safeParse(fn, file, verbose) {
|
|
195
|
+
try { return fn(file); }
|
|
196
|
+
catch (e) {
|
|
197
|
+
if (verbose) console.warn(`⚠️ parse failed: ${file} — ${e.message}`);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function hasJsManifests(rootDir) {
|
|
203
|
+
try {
|
|
204
|
+
const stack = [rootDir];
|
|
205
|
+
const skip = new Set([
|
|
206
|
+
"node_modules", "bower_components", "jspm_packages",
|
|
207
|
+
".git", ".idea", ".vscode", ".gradle", ".mvn",
|
|
208
|
+
"dist", "build", "out", "target", "coverage", ".next", ".nuxt",
|
|
209
|
+
]);
|
|
210
|
+
while (stack.length) {
|
|
211
|
+
const cur = stack.pop();
|
|
212
|
+
let entries;
|
|
213
|
+
try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
|
|
214
|
+
catch { continue; }
|
|
215
|
+
for (const e of entries) {
|
|
216
|
+
if (e.isFile() && (e.name === "package.json" || e.name === "package-lock.json" || e.name === "yarn.lock")) return true;
|
|
217
|
+
if (e.isDirectory() && !skip.has(e.name)) stack.push(path.join(cur, e.name));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch { /* ignore */ }
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { collectNpmDeps, hasJsManifests };
|