fad-checker 1.0.6 → 2.0.1
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 +62 -0
- package/README.md +77 -15
- package/completions/fad-checker.bash +4 -1
- package/completions/fad-checker.zsh +9 -0
- package/data/eol-mapping.json +17 -0
- package/fad-checker.js +353 -241
- 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 +58 -35
- package/lib/{npm → codecs/npm}/parse.js +114 -10
- package/lib/{npm → codecs/npm}/registry.js +4 -3
- 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 +94 -0
- package/lib/codecs/pypi/parse.js +163 -0
- package/lib/codecs/pypi/registry.js +89 -0
- package/lib/codecs/pypi.codec.js +102 -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/core.js +35 -1
- package/lib/cve-match.js +18 -11
- package/lib/cve-report.js +34 -70
- package/lib/dep-record.js +60 -0
- package/lib/deps-descriptor.js +110 -0
- package/lib/nvd.js +4 -3
- package/lib/osv.js +29 -18
- package/lib/outdated.js +20 -4
- package/lib/retire.js +77 -13
- package/lib/transitive.js +3 -3
- package/lib/ui.js +87 -0
- package/package.json +4 -2
- package/CLAUDE.md +0 -129
- package/docs/ARCHITECTURE.md +0 -154
- package/docs/USAGE.md +0 -179
- package/test/core.test.js +0 -153
- package/test/cpe.test.js +0 -166
- package/test/cve-download.test.js +0 -39
- package/test/cve-match.test.js +0 -217
- package/test/cve-report.test.js +0 -171
- package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
- package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
- package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
- package/test/fixtures/complex-enterprise/pom.xml +0 -66
- package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
- package/test/fixtures/cve-samples/cve-non-java.json +0 -19
- package/test/fixtures/cve-samples/cve-product-only.json +0 -31
- package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
- package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
- package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
- package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
- package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
- package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -9
- package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
- package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
- package/test/fixtures/monorepo-mixed/pom.xml +0 -29
- package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
- package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
- package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
- package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
- package/test/fixtures/private-lib-detection/pom.xml +0 -35
- package/test/fixtures/simple/app/pom.xml +0 -28
- package/test/fixtures/simple/lib/pom.xml +0 -18
- package/test/fixtures/simple/pom.xml +0 -24
- package/test/maven-repo.test.js +0 -111
- package/test/maven-version.test.js +0 -57
- package/test/monorepo.test.js +0 -132
- package/test/npm-registry.test.js +0 -64
- package/test/npm.test.js +0 -146
- package/test/outdated.test.js +0 -101
- package/test/snyk.test.js +0 -64
- package/test/transitive.test.js +0 -305
- package/test/webjar.test.js +0 -33
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
const fs = require("fs");
|
|
26
26
|
const path = require("path");
|
|
27
|
+
const yaml = require("js-yaml");
|
|
27
28
|
|
|
28
29
|
function isWorkspaceVersion(v) {
|
|
29
30
|
// npm v9+ uses "*" / "" for workspace-local refs; yarn uses "workspace:*"
|
|
@@ -189,14 +190,8 @@ function parsePackageLock(filePath) {
|
|
|
189
190
|
function parseYarnLockV1(filePath) {
|
|
190
191
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
191
192
|
if (raw.includes("__metadata:")) {
|
|
192
|
-
// Berry / yarn 2+ uses YAML
|
|
193
|
-
return
|
|
194
|
-
manifestPath: filePath,
|
|
195
|
-
manifestType: "yarn.lock",
|
|
196
|
-
lockfileVersion: "berry",
|
|
197
|
-
deps: [],
|
|
198
|
-
unsupported: "yarn-berry",
|
|
199
|
-
};
|
|
193
|
+
// Berry / yarn 2+ uses YAML — parse it with js-yaml.
|
|
194
|
+
return parseYarnBerry(raw, filePath);
|
|
200
195
|
}
|
|
201
196
|
const out = {
|
|
202
197
|
manifestPath: filePath,
|
|
@@ -247,6 +242,112 @@ function parseYarnLockV1(filePath) {
|
|
|
247
242
|
return out;
|
|
248
243
|
}
|
|
249
244
|
|
|
245
|
+
/* -------- yarn.lock v2+ (Berry) ----------
|
|
246
|
+
Berry lockfiles are YAML. Top-level keys are comma-separated descriptor lists
|
|
247
|
+
("lodash@npm:^4.17.0, lodash@npm:^4.17.21:") whose value carries `version` and
|
|
248
|
+
`resolution`. The workspace's own packages resolve to "@workspace:" — skipped.
|
|
249
|
+
*/
|
|
250
|
+
function identFromDescriptor(d) {
|
|
251
|
+
const s = String(d || "").trim().replace(/^"|"$/g, "");
|
|
252
|
+
const scoped = s.startsWith("@");
|
|
253
|
+
const at = s.indexOf("@", scoped ? 1 : 0); // the @ before the range/protocol
|
|
254
|
+
if (at <= 0) return null;
|
|
255
|
+
return s.slice(0, at);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseYarnBerry(raw, filePath) {
|
|
259
|
+
const out = { manifestPath: filePath, manifestType: "yarn.lock", lockfileVersion: "berry", deps: [] };
|
|
260
|
+
let doc;
|
|
261
|
+
try { doc = yaml.load(raw) || {}; }
|
|
262
|
+
catch (e) { out.parseError = e.message; return out; }
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
for (const [key, val] of Object.entries(doc)) {
|
|
265
|
+
if (key === "__metadata") continue;
|
|
266
|
+
if (!val || typeof val !== "object" || !val.version) continue;
|
|
267
|
+
if (/@workspace:/.test(val.resolution || "")) continue; // the local package itself
|
|
268
|
+
const version = String(val.version);
|
|
269
|
+
const names = new Set();
|
|
270
|
+
for (const desc of String(key).split(",")) {
|
|
271
|
+
const name = identFromDescriptor(desc);
|
|
272
|
+
if (name) names.add(name);
|
|
273
|
+
}
|
|
274
|
+
for (const name of names) {
|
|
275
|
+
const k = `${name}@${version}`;
|
|
276
|
+
if (seen.has(k)) continue; seen.add(k);
|
|
277
|
+
out.deps.push({ name, version, scope: "prod", depth: 0, resolved: val.resolution || null });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* -------- pnpm-lock.yaml ----------
|
|
284
|
+
YAML. The full resolved set lives in `packages` (v5/v6) and `snapshots` (v9);
|
|
285
|
+
`importers.*` carry the per-workspace direct deps with dev classification.
|
|
286
|
+
Package keys vary by lockfileVersion:
|
|
287
|
+
v9: "name@1.2.3" / "@scope/name@1.2.3"
|
|
288
|
+
v6: "/name@1.2.3(peers)" / "/@scope/name@1.2.3(peers)"
|
|
289
|
+
v5: "/name/1.2.3" / "/@scope/name/1.2.3"
|
|
290
|
+
*/
|
|
291
|
+
function pnpmNameVersion(key) {
|
|
292
|
+
let k = String(key || "");
|
|
293
|
+
if (k.startsWith("/")) k = k.slice(1);
|
|
294
|
+
const paren = k.indexOf("("); // strip peer-deps suffix
|
|
295
|
+
if (paren !== -1) k = k.slice(0, paren);
|
|
296
|
+
const at = k.lastIndexOf("@"); // @-form (v6/v9)
|
|
297
|
+
if (at > 0 && /^\d/.test(k.slice(at + 1))) return { name: k.slice(0, at), version: k.slice(at + 1) };
|
|
298
|
+
const slash = k.lastIndexOf("/"); // slash-form (v5)
|
|
299
|
+
if (slash > 0 && /^\d/.test(k.slice(slash + 1))) return { name: k.slice(0, slash), version: k.slice(slash + 1) };
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function parsePnpmLock(filePath) {
|
|
304
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
305
|
+
let doc;
|
|
306
|
+
try { doc = yaml.load(raw) || {}; }
|
|
307
|
+
catch (e) { throw new Error(`pnpm-lock parse failed (${filePath}): ${e.message}`); }
|
|
308
|
+
const out = { manifestPath: filePath, manifestType: "pnpm-lock", lockfileVersion: doc.lockfileVersion || null, deps: [] };
|
|
309
|
+
const seen = new Set();
|
|
310
|
+
const push = (name, version, scope, isDev) => {
|
|
311
|
+
if (!name || !version) return;
|
|
312
|
+
const v = String(version).split("(")[0]; // peer-resolved suffix
|
|
313
|
+
if (!/^\d/.test(v)) return;
|
|
314
|
+
const k = `${name}@${v}`;
|
|
315
|
+
if (seen.has(k)) return; seen.add(k);
|
|
316
|
+
out.deps.push({ name, version: v, scope: scope || "prod", isDev: !!isDev, depth: 0 });
|
|
317
|
+
};
|
|
318
|
+
// Direct deps from importers FIRST (v6/v9) — these carry the authoritative
|
|
319
|
+
// dev/optional classification. In v9 the `packages` section has no dev flag,
|
|
320
|
+
// so seeding dev/optional here (dedup by name@version) keeps the right scope.
|
|
321
|
+
const importers = doc.importers || ((doc.dependencies || doc.devDependencies) ? { ".": doc } : null);
|
|
322
|
+
if (importers && typeof importers === "object") {
|
|
323
|
+
const readDir = (obj, scope, isDev) => {
|
|
324
|
+
for (const [name, spec] of Object.entries(obj || {})) {
|
|
325
|
+
const version = typeof spec === "string" ? spec : (spec && (spec.version || spec.specifier));
|
|
326
|
+
push(name, version, scope, isDev);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
for (const imp of Object.values(importers)) {
|
|
330
|
+
if (!imp || typeof imp !== "object") continue;
|
|
331
|
+
readDir(imp.devDependencies, "dev", true);
|
|
332
|
+
readDir(imp.optionalDependencies, "optional", false);
|
|
333
|
+
readDir(imp.dependencies, "prod", false);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Full resolved set (direct + transitive) fills in anything not already seen.
|
|
337
|
+
for (const sec of [doc.packages, doc.snapshots]) {
|
|
338
|
+
if (!sec || typeof sec !== "object") continue;
|
|
339
|
+
for (const [key, entry] of Object.entries(sec)) {
|
|
340
|
+
const nv = pnpmNameVersion(key);
|
|
341
|
+
if (!nv) continue;
|
|
342
|
+
const name = (entry && entry.name) || nv.name;
|
|
343
|
+
const version = (entry && entry.version) || nv.version;
|
|
344
|
+
const isDev = !!(entry && entry.dev); // v6 carries a dev flag
|
|
345
|
+
push(name, version, isDev ? "dev" : "prod", isDev);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
250
351
|
/* -------- discovery ---------- */
|
|
251
352
|
// Dirs that hold packaged / generated content — never our own source.
|
|
252
353
|
// Conservative list: only well-known build-output / package-cache dirs.
|
|
@@ -266,7 +367,7 @@ function findJsManifests(rootDir, opts = {}) {
|
|
|
266
367
|
try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
|
|
267
368
|
catch { continue; }
|
|
268
369
|
// Group lockfile per directory so we can prefer lock > package.json
|
|
269
|
-
const here = { dir: cur, packageJson: null, packageLock: null, yarnLock: null };
|
|
370
|
+
const here = { dir: cur, packageJson: null, packageLock: null, yarnLock: null, pnpmLock: null };
|
|
270
371
|
for (const e of entries) {
|
|
271
372
|
const p = path.join(cur, e.name);
|
|
272
373
|
if (e.isDirectory()) {
|
|
@@ -276,9 +377,10 @@ function findJsManifests(rootDir, opts = {}) {
|
|
|
276
377
|
if (e.name === "package.json") here.packageJson = p;
|
|
277
378
|
else if (e.name === "package-lock.json") here.packageLock = p;
|
|
278
379
|
else if (e.name === "yarn.lock") here.yarnLock = p;
|
|
380
|
+
else if (e.name === "pnpm-lock.yaml") here.pnpmLock = p;
|
|
279
381
|
}
|
|
280
382
|
}
|
|
281
|
-
if (here.packageJson || here.packageLock || here.yarnLock) found.push(here);
|
|
383
|
+
if (here.packageJson || here.packageLock || here.yarnLock || here.pnpmLock) found.push(here);
|
|
282
384
|
}
|
|
283
385
|
return found;
|
|
284
386
|
}
|
|
@@ -287,5 +389,7 @@ module.exports = {
|
|
|
287
389
|
parsePackageJson,
|
|
288
390
|
parsePackageLock,
|
|
289
391
|
parseYarnLockV1,
|
|
392
|
+
parseYarnBerry,
|
|
393
|
+
parsePnpmLock,
|
|
290
394
|
findJsManifests,
|
|
291
395
|
};
|
|
@@ -118,7 +118,7 @@ async function fetchPackument(name, opts = {}) {
|
|
|
118
118
|
* opts: { verbose, offline, allLibs, concurrency = 8 }
|
|
119
119
|
*/
|
|
120
120
|
async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
|
|
121
|
-
const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
|
|
121
|
+
const { verbose, offline, allLibs = true, concurrency = 8, onProgress } = opts;
|
|
122
122
|
// Targets = npm deps (queried by their own name) + WebJars (queried by their
|
|
123
123
|
// derived npm-equivalent name; version matches upstream). The original dep
|
|
124
124
|
// is kept for display/results so the report shows e.g. org.webjars:angularjs.
|
|
@@ -138,7 +138,7 @@ async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
|
|
|
138
138
|
|
|
139
139
|
const cacheKey = t => `${t.npmName}@${t.version}`;
|
|
140
140
|
const liveCount = offline ? 0 : targets.filter(t => !cache.entries[cacheKey(t)]).length;
|
|
141
|
-
if (liveCount && !offline) {
|
|
141
|
+
if (liveCount && !offline && !onProgress) {
|
|
142
142
|
console.log(`📦 npm registry: checking ${targets.length} packages for deprecation${allLibs ? " + outdated" : ""} (${liveCount} live, ${targets.length - liveCount} cached)…`);
|
|
143
143
|
}
|
|
144
144
|
|
|
@@ -147,6 +147,7 @@ async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
|
|
|
147
147
|
let processed = 0;
|
|
148
148
|
const startedAt = Date.now();
|
|
149
149
|
const printProgress = (final = false) => {
|
|
150
|
+
if (onProgress) { onProgress(processed, targets.length); return; }
|
|
150
151
|
if (!liveCount) return;
|
|
151
152
|
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
|
152
153
|
const pct = Math.round((processed / targets.length) * 100);
|
|
@@ -199,7 +200,7 @@ async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
|
|
|
199
200
|
|
|
200
201
|
result.deprecated.sort((a, b) => a.dep.artifactId.localeCompare(b.dep.artifactId));
|
|
201
202
|
result.outdated.sort((a, b) => a.dep.artifactId.localeCompare(b.dep.artifactId));
|
|
202
|
-
if (liveCount && !offline) console.log(` npm registry: ${result.deprecated.length} deprecated, ${result.outdated.length} outdated`);
|
|
203
|
+
if (liveCount && !offline && !onProgress) console.log(` npm registry: ${result.deprecated.length} deprecated, ${result.outdated.length} outdated`);
|
|
203
204
|
return result;
|
|
204
205
|
}
|
|
205
206
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/npm.codec.js — codec npm.
|
|
3
|
+
*
|
|
4
|
+
* Enveloppe les parsers lib/codecs/npm/parse.js + le collecteur lib/codecs/npm/collect.js
|
|
5
|
+
* (package-lock v1/2/3, yarn.lock v1) et le scanner natif retire.js.
|
|
6
|
+
* Aucune logique nouvelle : extraction derrière l'interface codec.
|
|
7
|
+
*
|
|
8
|
+
* npm.collect ramasse package-lock ET yarn.lock — chaque dep porte son
|
|
9
|
+
* `ecosystemType` ("npm" | "yarn"). Le codec yarn (yarn.codec.js) n'existe que
|
|
10
|
+
* pour fournir son label/recette au report ; il ne re-scanne pas.
|
|
11
|
+
*/
|
|
12
|
+
const { collectNpmDeps, hasJsManifests } = require("./npm/collect");
|
|
13
|
+
const { coordKeyFor } = require("../dep-record");
|
|
14
|
+
|
|
15
|
+
// Scanner natif : retire.js (JS vendored sans lockfile). scan(deps,opts) → {matches,meta}.
|
|
16
|
+
const retireScanner = {
|
|
17
|
+
id: "retire",
|
|
18
|
+
kind: "vendored", // résultats → chapitre vendored-JS (séparé des CVE)
|
|
19
|
+
async scan(_deps, opts = {}) {
|
|
20
|
+
const { scanWithRetire } = require("../retire");
|
|
21
|
+
const matches = await scanWithRetire(opts.src, { verbose: opts.verbose, force: !!opts.retireRefresh, offline: !!opts.offline });
|
|
22
|
+
return { matches, meta: {} };
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const base = {
|
|
27
|
+
osvEcosystem: "npm",
|
|
28
|
+
manifestNames: ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"],
|
|
29
|
+
detect(dir) { return hasJsManifests(dir); },
|
|
30
|
+
coordKey(d) { return coordKeyFor("npm", "", d.name || d.artifactId); },
|
|
31
|
+
formatCoord(d) { return d.name || d.artifactId; },
|
|
32
|
+
osvPackageName(d) { return d.name || d.artifactId; },
|
|
33
|
+
async checkRegistry(deps, opts = {}) {
|
|
34
|
+
const { checkNpmRegistryDeps } = require("./npm/registry");
|
|
35
|
+
const r = await checkNpmRegistryDeps(deps, opts);
|
|
36
|
+
return { outdated: r.outdated || [], deprecated: r.deprecated || [] };
|
|
37
|
+
},
|
|
38
|
+
resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
|
|
39
|
+
nativeScanners: [retireScanner],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
module.exports = {
|
|
43
|
+
...base,
|
|
44
|
+
id: "npm",
|
|
45
|
+
label: "npm",
|
|
46
|
+
recipe: require("./recipes").npm,
|
|
47
|
+
// collect ramasse TOUTES les deps JS (package-lock + yarn.lock).
|
|
48
|
+
async collect(dir, opts = {}) {
|
|
49
|
+
const deps = collectNpmDeps(dir, opts);
|
|
50
|
+
return { deps, warnings: deps.warnings || [] };
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/nuget/parse.js — parse .NET/NuGet manifests.
|
|
3
|
+
*
|
|
4
|
+
* packages.lock.json — JSON, { dependencies: { "<tfm>": { "<id>": { type, resolved } } } }
|
|
5
|
+
* type "Direct"|"Transitive"; resolved = concrete version.
|
|
6
|
+
* *.csproj — XML, <PackageReference Include Version>. Version may be an
|
|
7
|
+
* attribute, a child element, or absent (Central Package Management).
|
|
8
|
+
* Directory.Packages.props — XML CPM, <PackageVersion Include Version> → name→version table.
|
|
9
|
+
* packages.config — XML legacy, <package id version targetFramework />.
|
|
10
|
+
*
|
|
11
|
+
* NuGet ids are case-insensitive (lowercased for the key; original case kept for display).
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const xml2js = require("xml2js");
|
|
15
|
+
|
|
16
|
+
// Concrete = starts with a digit and only [\w.+-] — rejects floating "1.*",
|
|
17
|
+
// range "[1.0,2.0)", and wildcard/comma/bracket specifiers.
|
|
18
|
+
function isConcrete(v) { const s = String(v || ""); return /^\d/.test(s) && /^[\w.+-]+$/.test(s); }
|
|
19
|
+
|
|
20
|
+
async function parsePackagesLockJson(filePath) {
|
|
21
|
+
const json = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
22
|
+
const deps = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
for (const fw of Object.values(json.dependencies || {})) {
|
|
25
|
+
for (const [name, meta] of Object.entries(fw || {})) {
|
|
26
|
+
const version = meta.resolved || null;
|
|
27
|
+
if (!version) continue;
|
|
28
|
+
const key = `${name.toLowerCase()}@${version}`;
|
|
29
|
+
if (seen.has(key)) continue; seen.add(key);
|
|
30
|
+
const scope = (meta.type === "Transitive") ? "transitive" : "prod";
|
|
31
|
+
deps.push({ name, version, scope, isDev: false });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { manifestPath: filePath, manifestType: "packages.lock.json", deps };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function parseDirectoryPackagesProps(filePath) {
|
|
38
|
+
const xml = await xml2js.parseStringPromise(fs.readFileSync(filePath, "utf8"));
|
|
39
|
+
const map = {};
|
|
40
|
+
for (const ig of xml.Project?.ItemGroup || []) {
|
|
41
|
+
for (const pv of ig.PackageVersion || []) {
|
|
42
|
+
const id = pv.$?.Include; const v = pv.$?.Version;
|
|
43
|
+
if (id && v) map[id.toLowerCase()] = v;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return map;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function parseCsproj(filePath, cpm = {}) {
|
|
50
|
+
const xml = await xml2js.parseStringPromise(fs.readFileSync(filePath, "utf8"));
|
|
51
|
+
const deps = [];
|
|
52
|
+
let skipped = 0;
|
|
53
|
+
for (const ig of xml.Project?.ItemGroup || []) {
|
|
54
|
+
for (const pr of ig.PackageReference || []) {
|
|
55
|
+
const name = pr.$?.Include; if (!name) continue;
|
|
56
|
+
// Version: attribute, child element, or (CPM) resolved from Directory.Packages.props.
|
|
57
|
+
const version = pr.$?.Version || (Array.isArray(pr.Version) ? pr.Version[0] : null) || cpm[name.toLowerCase()] || null;
|
|
58
|
+
if (version && isConcrete(version)) deps.push({ name, version, scope: "prod", isDev: false });
|
|
59
|
+
else skipped++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { manifestPath: filePath, manifestType: "csproj", deps, skipped };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function parsePackagesConfig(filePath) {
|
|
66
|
+
const xml = await xml2js.parseStringPromise(fs.readFileSync(filePath, "utf8"));
|
|
67
|
+
const deps = [];
|
|
68
|
+
for (const p of xml.packages?.package || []) {
|
|
69
|
+
const name = p.$?.id; const version = p.$?.version;
|
|
70
|
+
if (name && version && isConcrete(version)) deps.push({ name, version, scope: "prod", isDev: false });
|
|
71
|
+
}
|
|
72
|
+
return { manifestPath: filePath, manifestType: "packages.config", deps };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { isConcrete, parsePackagesLockJson, parseDirectoryPackagesProps, parseCsproj, parsePackagesConfig };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/nuget/registry.js — query the NuGet registration index for a package's
|
|
3
|
+
* latest stable version and per-version deprecation.
|
|
4
|
+
*
|
|
5
|
+
* API: https://api.nuget.org/v3/registration5-gz-semver2/{lowerid}/index.json
|
|
6
|
+
* → { items: [ { items: [ { catalogEntry: { version, deprecation } } ] } ] }
|
|
7
|
+
*
|
|
8
|
+
* Cached in ~/.fad-checker/nuget-cache.json for 24h.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const os = require("os");
|
|
13
|
+
let pLimit; try { pLimit = require("p-limit"); } catch { pLimit = () => (fn) => fn(); }
|
|
14
|
+
|
|
15
|
+
const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
|
|
16
|
+
const CACHE_PATH = path.join(CACHE_DIR, "nuget-cache.json");
|
|
17
|
+
const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
|
|
18
|
+
const REG = "https://api.nuget.org/v3/registration5-gz-semver2";
|
|
19
|
+
|
|
20
|
+
function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); } catch { return { entries: {}, meta: {} }; } }
|
|
21
|
+
function saveCache(d) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify(d)); } catch { /* ignore */ } }
|
|
22
|
+
function isStable(v) { return /^\d+(\.\d+)*$/.test(String(v || "")); }
|
|
23
|
+
function cmp(a, b) {
|
|
24
|
+
const pa = String(a).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
|
|
25
|
+
const pb = String(b).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
|
|
26
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d > 0 ? 1 : -1; }
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Walk a registration index (inline items) → { outdated, deprecated }.
|
|
31
|
+
function nugetRegistrationToFindings(reg, { version }) {
|
|
32
|
+
const entries = [];
|
|
33
|
+
for (const page of reg.items || []) for (const leaf of page.items || []) if (leaf.catalogEntry) entries.push(leaf.catalogEntry);
|
|
34
|
+
const out = { outdated: null, deprecated: null };
|
|
35
|
+
const stable = entries.map(e => e.version).filter(isStable);
|
|
36
|
+
if (stable.length) { const latest = stable.sort(cmp).at(-1); if (latest && cmp(latest, version) > 0) out.outdated = { latest }; }
|
|
37
|
+
const mine = entries.find(e => String(e.version) === String(version));
|
|
38
|
+
if (mine?.deprecation) out.deprecated = { reason: (mine.deprecation.reasons || []).join(", ") || "deprecated", replacement: mine.deprecation.alternatePackage?.id || null };
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fetchRegistration(name, { offline }) {
|
|
43
|
+
if (offline) return null;
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`${REG}/${name.toLowerCase()}/index.json`, { headers: { "User-Agent": "fad-checker-nuget" } });
|
|
46
|
+
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
47
|
+
return await res.json();
|
|
48
|
+
} catch (e) { return { error: e.message }; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Mirror of checkNpmRegistryDeps: returns { deprecated:[], outdated:[] }.
|
|
52
|
+
async function checkNugetRegistryDeps(deps, opts = {}) {
|
|
53
|
+
const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
|
|
54
|
+
const targets = [...deps.values()].filter(d => d.ecosystem === "nuget" && d.version);
|
|
55
|
+
const result = { deprecated: [], outdated: [] };
|
|
56
|
+
if (!targets.length) return result;
|
|
57
|
+
const cache = loadCache();
|
|
58
|
+
const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
|
|
59
|
+
if (!fresh && !offline) cache.entries = {};
|
|
60
|
+
const limit = pLimit(concurrency);
|
|
61
|
+
await Promise.all(targets.map(t => limit(async () => {
|
|
62
|
+
const key = `${t.name.toLowerCase()}@${t.version}`;
|
|
63
|
+
let ex = cache.entries[key];
|
|
64
|
+
if (!ex) {
|
|
65
|
+
const reg = await fetchRegistration(t.name, { offline });
|
|
66
|
+
if (reg && !reg.error) {
|
|
67
|
+
const f = nugetRegistrationToFindings(reg, { version: t.version });
|
|
68
|
+
ex = { deprecated: f.deprecated, latest: f.outdated?.latest || null };
|
|
69
|
+
cache.entries[key] = ex;
|
|
70
|
+
} else {
|
|
71
|
+
ex = { deprecated: null, latest: null };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (ex.deprecated) {
|
|
75
|
+
result.deprecated.push({ dep: t, severity: "MEDIUM", replacement: ex.deprecated.replacement, reason: ex.deprecated.reason, source: "nuget" });
|
|
76
|
+
if (verbose) process.stdout.write(` deprecated: ${t.name}@${t.version}\n`);
|
|
77
|
+
}
|
|
78
|
+
if (allLibs && ex.latest) {
|
|
79
|
+
result.outdated.push({ dep: t, latest: ex.latest, releaseDate: null });
|
|
80
|
+
if (verbose) process.stdout.write(` outdated: ${t.name} ${t.version} → ${ex.latest}\n`);
|
|
81
|
+
}
|
|
82
|
+
})));
|
|
83
|
+
cache.meta = { fetchedAt: Date.now() };
|
|
84
|
+
saveCache(cache);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { nugetRegistrationToFindings, checkNugetRegistryDeps };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/nuget.codec.js — codec C#/.NET (NuGet).
|
|
3
|
+
*
|
|
4
|
+
* Vuln scanning is OSV (ecosystem "NuGet", wired in Plan A). This codec adds
|
|
5
|
+
* collection (packages.lock.json, else .csproj + Directory.Packages.props CPM,
|
|
6
|
+
* else packages.config), NuGet registration registry (deprecation + outdated),
|
|
7
|
+
* and EOL. NuGet ids are case-insensitive: the key is lowercased, dep.name keeps
|
|
8
|
+
* the original casing for display / OSV.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const { makeDepRecord, coordKeyFor } = require("../dep-record");
|
|
13
|
+
const N = require("./nuget/parse");
|
|
14
|
+
|
|
15
|
+
const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "bin", "obj", "target", "packages"]);
|
|
16
|
+
// MSBuild project files share the same <PackageReference> schema — C#, F#, VB.
|
|
17
|
+
const PROJ_RE = /\.(csproj|fsproj|vbproj)$/i;
|
|
18
|
+
|
|
19
|
+
function findNugetDirs(dir) {
|
|
20
|
+
const groups = [];
|
|
21
|
+
const stack = [dir];
|
|
22
|
+
while (stack.length) {
|
|
23
|
+
const cur = stack.pop();
|
|
24
|
+
let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
25
|
+
const files = entries.filter(e => e.isFile()).map(e => e.name);
|
|
26
|
+
const csprojs = files.filter(f => PROJ_RE.test(f));
|
|
27
|
+
if (files.includes("packages.lock.json") || files.includes("packages.config") || csprojs.length) {
|
|
28
|
+
groups.push({ dir: cur, files, csprojs });
|
|
29
|
+
}
|
|
30
|
+
for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
|
|
31
|
+
}
|
|
32
|
+
return groups;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = {
|
|
36
|
+
id: "nuget",
|
|
37
|
+
label: "NuGet",
|
|
38
|
+
osvEcosystem: "NuGet",
|
|
39
|
+
manifestNames: ["packages.lock.json", "*.csproj", "*.fsproj", "*.vbproj", "packages.config"],
|
|
40
|
+
|
|
41
|
+
detect(dir) { return findNugetDirs(dir).length > 0; },
|
|
42
|
+
|
|
43
|
+
async collect(dir, opts = {}) {
|
|
44
|
+
const { deps2Exclude } = opts;
|
|
45
|
+
const out = new Map();
|
|
46
|
+
const warnings = [];
|
|
47
|
+
const add = (d, manifestPath) => {
|
|
48
|
+
if (deps2Exclude && deps2Exclude.test(d.name)) return;
|
|
49
|
+
out.set(coordKeyFor("nuget", "", d.name), makeDepRecord({ ecosystem: "nuget", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev }));
|
|
50
|
+
};
|
|
51
|
+
for (const g of findNugetDirs(dir)) {
|
|
52
|
+
if (g.files.includes("packages.lock.json")) {
|
|
53
|
+
const fp = path.join(g.dir, "packages.lock.json");
|
|
54
|
+
try { const { deps } = await N.parsePackagesLockJson(fp); for (const d of deps) add(d, fp); }
|
|
55
|
+
catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `packages.lock.json parse failed: ${e.message}` }); }
|
|
56
|
+
continue; // lockfile is authoritative for this directory
|
|
57
|
+
}
|
|
58
|
+
// No lockfile → best-effort from .csproj (+CPM) and packages.config + warning.
|
|
59
|
+
let cpm = {};
|
|
60
|
+
if (g.files.includes("Directory.Packages.props")) {
|
|
61
|
+
try { cpm = await N.parseDirectoryPackagesProps(path.join(g.dir, "Directory.Packages.props")); } catch { /* ignore */ }
|
|
62
|
+
}
|
|
63
|
+
let pinned = 0, skipped = 0;
|
|
64
|
+
for (const cs of g.csprojs) {
|
|
65
|
+
const fp = path.join(g.dir, cs);
|
|
66
|
+
try {
|
|
67
|
+
const { deps, skipped: sk } = await N.parseCsproj(fp, cpm);
|
|
68
|
+
for (const d of deps) { add(d, fp); pinned++; }
|
|
69
|
+
skipped += sk;
|
|
70
|
+
} catch { /* ignore unparsable csproj */ }
|
|
71
|
+
}
|
|
72
|
+
if (g.files.includes("packages.config")) {
|
|
73
|
+
const fp = path.join(g.dir, "packages.config");
|
|
74
|
+
try { const { deps } = await N.parsePackagesConfig(fp); for (const d of deps) { add(d, fp); pinned++; } } catch { /* ignore */ }
|
|
75
|
+
}
|
|
76
|
+
if (g.csprojs.length || g.files.includes("packages.config")) {
|
|
77
|
+
warnings.push({ type: "no-lockfile", manifestPath: g.dir, message: `no packages.lock.json — best-effort: ${pinned} pinned, ${skipped} floating/unresolved skipped (enable RestorePackagesWithLockFile)` });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { deps: out, warnings };
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
coordKey(d) { return coordKeyFor("nuget", "", d.name); },
|
|
84
|
+
formatCoord(d) { return d.name; },
|
|
85
|
+
osvPackageName(d) { return d.name; },
|
|
86
|
+
|
|
87
|
+
async checkRegistry(deps, opts = {}) {
|
|
88
|
+
const { checkNugetRegistryDeps } = require("./nuget/registry");
|
|
89
|
+
return checkNugetRegistryDeps(deps, opts);
|
|
90
|
+
},
|
|
91
|
+
resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
|
|
92
|
+
recipe: require("./recipes").nuget,
|
|
93
|
+
nativeScanners: [],
|
|
94
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/python/parse.js — parse Python manifests / lockfiles.
|
|
3
|
+
*
|
|
4
|
+
* poetry.lock / uv.lock / pdm.lock — TOML, [[package]] name/version arrays.
|
|
5
|
+
* Pipfile.lock — JSON, { default:{}, develop:{} }, version "==x".
|
|
6
|
+
* pyproject.toml — TOML, PEP 621 [project] + poetry table (fallback).
|
|
7
|
+
* requirements.txt — text; only "==" pins are queryable (fallback).
|
|
8
|
+
* -r/-c includes are followed recursively;
|
|
9
|
+
* a -c constraint pin upgrades a range to scannable.
|
|
10
|
+
*
|
|
11
|
+
* All names are PEP 503 normalized (lowercase, runs of -_. → single -) so they
|
|
12
|
+
* match OSV / PyPI / the resolved-deps key.
|
|
13
|
+
*/
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const TOML = require("smol-toml");
|
|
17
|
+
|
|
18
|
+
function pep503(name) { return String(name || "").toLowerCase().replace(/[-_.]+/g, "-"); }
|
|
19
|
+
|
|
20
|
+
function stripOp(v) { return String(v || "").replace(/^[=~!<>]+/, "").trim(); }
|
|
21
|
+
function isPinned(spec) { return /^==\s*\d[\w.\-+!]*$/.test(String(spec || "").trim()); }
|
|
22
|
+
function isConcrete(v) { return /^\d+(\.\d+)*([.\-+]\S+)?$/.test(String(v || "")); }
|
|
23
|
+
|
|
24
|
+
// Split a PEP 508 requirement string ("requests[extra]==2.31.0 ; marker") into
|
|
25
|
+
// { name, spec } — env markers (after ";") and extras ("[...]") are dropped.
|
|
26
|
+
function splitPep508(req) {
|
|
27
|
+
const s = String(req || "").split(";")[0].trim();
|
|
28
|
+
const m = s.match(/^([A-Za-z0-9._-]+)\s*(\[[^\]]*\])?\s*(.*)$/);
|
|
29
|
+
if (!m) return { name: null, spec: "" };
|
|
30
|
+
return { name: m[1], spec: m[3].trim() };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// poetry.lock / uv.lock / pdm.lock all use [[package]] name/version arrays.
|
|
34
|
+
function parseTomlPackages(filePath, type) {
|
|
35
|
+
const data = TOML.parse(fs.readFileSync(filePath, "utf8"));
|
|
36
|
+
const pkgs = Array.isArray(data.package) ? data.package : [];
|
|
37
|
+
const deps = [];
|
|
38
|
+
for (const p of pkgs) {
|
|
39
|
+
if (!p.name || !p.version) continue;
|
|
40
|
+
// pdm marks groups; poetry/uv don't reliably → default prod.
|
|
41
|
+
const groups = Array.isArray(p.groups) ? p.groups : null;
|
|
42
|
+
const isDev = groups ? groups.every(g => g === "dev") : false;
|
|
43
|
+
deps.push({ name: pep503(p.name), version: String(p.version), scope: isDev ? "dev" : "prod", isDev });
|
|
44
|
+
}
|
|
45
|
+
return { manifestPath: filePath, manifestType: type, deps };
|
|
46
|
+
}
|
|
47
|
+
const parsePoetryLock = f => parseTomlPackages(f, "poetry.lock");
|
|
48
|
+
const parseUvLock = f => parseTomlPackages(f, "uv.lock");
|
|
49
|
+
const parsePdmLock = f => parseTomlPackages(f, "pdm.lock");
|
|
50
|
+
|
|
51
|
+
function parsePipfileLock(filePath) {
|
|
52
|
+
const json = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
53
|
+
const deps = [];
|
|
54
|
+
const push = (obj, scope) => {
|
|
55
|
+
for (const [name, meta] of Object.entries(obj || {})) {
|
|
56
|
+
const v = stripOp(meta.version);
|
|
57
|
+
if (!v) continue;
|
|
58
|
+
deps.push({ name: pep503(name), version: v, scope, isDev: scope === "dev" });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
push(json.default, "prod");
|
|
62
|
+
push(json.develop, "dev");
|
|
63
|
+
return { manifestPath: filePath, manifestType: "Pipfile.lock", deps };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// pyproject.toml — fallback when no lockfile. Reads PEP 621 [project] and the
|
|
67
|
+
// poetry tool table. Only exact pins are scannable (PEP 621 "==x"; poetry exact
|
|
68
|
+
// "x"); ranges (^/~/>=/wildcards) are counted in `skipped` like requirements.txt.
|
|
69
|
+
function parsePyprojectToml(filePath) {
|
|
70
|
+
const data = TOML.parse(fs.readFileSync(filePath, "utf8"));
|
|
71
|
+
const deps = [];
|
|
72
|
+
let skipped = 0;
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
const push = (rawName, version, scope) => {
|
|
75
|
+
const name = pep503(rawName);
|
|
76
|
+
if (!name || name === "python") return;
|
|
77
|
+
const k = `${name}@${version}`;
|
|
78
|
+
if (seen.has(k)) return; seen.add(k);
|
|
79
|
+
deps.push({ name, version, scope, isDev: scope === "dev" });
|
|
80
|
+
};
|
|
81
|
+
// PEP 508 specs ("requests==2.31.0") — exact "==" pins only.
|
|
82
|
+
const addSpec = (req, scope) => {
|
|
83
|
+
const { name, spec } = splitPep508(req);
|
|
84
|
+
if (!name) return;
|
|
85
|
+
if (isPinned(spec)) push(name, stripOp(spec), scope);
|
|
86
|
+
else skipped++;
|
|
87
|
+
};
|
|
88
|
+
// Poetry constraints ("^2.0" / "==4.2" / bare exact "4.2.0" / { version }).
|
|
89
|
+
const addPoetry = (name, val, scope) => {
|
|
90
|
+
if (pep503(name) === "python") return; // the interpreter, not a dep
|
|
91
|
+
const spec = typeof val === "string" ? val : (val && typeof val === "object" ? (val.version || "") : "");
|
|
92
|
+
const s = String(spec || "").trim();
|
|
93
|
+
if (isConcrete(s)) push(name, s, scope); // poetry: bare version == exact
|
|
94
|
+
else if (isPinned(s)) push(name, stripOp(s), scope);
|
|
95
|
+
else skipped++;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// PEP 621 [project]
|
|
99
|
+
const proj = data.project || {};
|
|
100
|
+
for (const req of (Array.isArray(proj.dependencies) ? proj.dependencies : [])) addSpec(req, "prod");
|
|
101
|
+
for (const [group, arr] of Object.entries(proj["optional-dependencies"] || {})) {
|
|
102
|
+
const isDev = /^(dev|test|tests|lint|docs|typing|type|check)$/i.test(group);
|
|
103
|
+
for (const req of (Array.isArray(arr) ? arr : [])) addSpec(req, isDev ? "dev" : "prod");
|
|
104
|
+
}
|
|
105
|
+
// Poetry [tool.poetry]
|
|
106
|
+
const poetry = (data.tool && data.tool.poetry) || {};
|
|
107
|
+
for (const [name, v] of Object.entries(poetry.dependencies || {})) addPoetry(name, v, "prod");
|
|
108
|
+
for (const [name, v] of Object.entries(poetry["dev-dependencies"] || {})) addPoetry(name, v, "dev");
|
|
109
|
+
for (const grp of Object.values(poetry.group || {})) {
|
|
110
|
+
for (const [name, v] of Object.entries((grp && grp.dependencies) || {})) addPoetry(name, v, "dev");
|
|
111
|
+
}
|
|
112
|
+
return { manifestPath: filePath, manifestType: "pyproject.toml", deps, skipped };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Walk a requirements.txt graph, following -r/--requirement (deps) and
|
|
116
|
+
// -c/--constraint (version pins) includes recursively. `kind` is "req" for the
|
|
117
|
+
// dependency graph and "constraint" for files reached via -c.
|
|
118
|
+
function collectReqGraph(filePath, seen, kind, acc) {
|
|
119
|
+
const real = path.resolve(filePath);
|
|
120
|
+
if (seen.has(real)) return; // cycle / re-include guard
|
|
121
|
+
seen.add(real);
|
|
122
|
+
if (seen.size > 200) return; // sanity bound
|
|
123
|
+
let lines;
|
|
124
|
+
try { lines = fs.readFileSync(real, "utf8").split(/\r?\n/); }
|
|
125
|
+
catch { acc.missing.push(real); return; }
|
|
126
|
+
const dir = path.dirname(real);
|
|
127
|
+
for (const raw of lines) {
|
|
128
|
+
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
129
|
+
if (!line) continue;
|
|
130
|
+
const rMatch = line.match(/^(?:-r|--requirement)[=\s]+(.+)$/);
|
|
131
|
+
if (rMatch) { collectReqGraph(path.resolve(dir, rMatch[1].trim()), seen, kind, acc); continue; }
|
|
132
|
+
const cMatch = line.match(/^(?:-c|--constraint)[=\s]+(.+)$/);
|
|
133
|
+
if (cMatch) { collectReqGraph(path.resolve(dir, cMatch[1].trim()), seen, "constraint", acc); continue; }
|
|
134
|
+
if (line.startsWith("-")) continue; // -e ., --hash, other flags
|
|
135
|
+
const { name, spec } = splitPep508(line);
|
|
136
|
+
if (!name) continue;
|
|
137
|
+
const n = pep503(name);
|
|
138
|
+
if (kind === "constraint") acc.constraints.set(n, spec);
|
|
139
|
+
else acc.reqs.push({ name: n, spec });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseRequirementsTxt(filePath) {
|
|
144
|
+
const acc = { reqs: [], constraints: new Map(), missing: [] };
|
|
145
|
+
collectReqGraph(filePath, new Set(), "req", acc);
|
|
146
|
+
const deps = [];
|
|
147
|
+
let skipped = 0;
|
|
148
|
+
const seen = new Set();
|
|
149
|
+
for (const r of acc.reqs) {
|
|
150
|
+
let version = null;
|
|
151
|
+
if (isPinned(r.spec)) version = stripOp(r.spec);
|
|
152
|
+
else if (acc.constraints.has(r.name) && isPinned(acc.constraints.get(r.name))) {
|
|
153
|
+
version = stripOp(acc.constraints.get(r.name)); // range pinned by -c constraint
|
|
154
|
+
}
|
|
155
|
+
if (!version) { skipped++; continue; }
|
|
156
|
+
const k = `${r.name}@${version}`;
|
|
157
|
+
if (seen.has(k)) continue; seen.add(k);
|
|
158
|
+
deps.push({ name: r.name, version, scope: "prod", isDev: false });
|
|
159
|
+
}
|
|
160
|
+
return { manifestPath: filePath, manifestType: "requirements.txt", deps, skipped, missing: acc.missing };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { pep503, isConcrete, splitPep508, parsePoetryLock, parseUvLock, parsePdmLock, parsePipfileLock, parsePyprojectToml, parseRequirementsTxt };
|