@4da/mcp-server 6.2.4 → 6.2.5

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 (119) hide show
  1. package/CHANGELOG.md +484 -0
  2. package/README.md +22 -6
  3. package/dist/db.d.ts +38 -0
  4. package/dist/db.js +78 -23
  5. package/dist/doctor.js +6 -3
  6. package/dist/embeddings.d.ts +1 -1
  7. package/dist/embeddings.js +16 -14
  8. package/dist/index.js +73 -103
  9. package/dist/live/advisory-groups.d.ts +68 -0
  10. package/dist/live/advisory-groups.js +131 -0
  11. package/dist/live/cache.js +11 -3
  12. package/dist/live/cargo-platform.d.ts +6 -0
  13. package/dist/live/cargo-platform.js +8 -2
  14. package/dist/live/changelog.js +38 -6
  15. package/dist/live/dependency-set.d.ts +9 -1
  16. package/dist/live/dependency-set.js +10 -2
  17. package/dist/live/fix-paths.d.ts +258 -9
  18. package/dist/live/fix-paths.js +790 -45
  19. package/dist/live/github-releases.js +3 -0
  20. package/dist/live/held-dependencies.d.ts +93 -0
  21. package/dist/live/held-dependencies.js +212 -0
  22. package/dist/live/held-targets.d.ts +30 -0
  23. package/dist/live/held-targets.js +107 -0
  24. package/dist/live/http-utils.js +13 -3
  25. package/dist/live/index.d.ts +7 -4
  26. package/dist/live/index.js +24 -19
  27. package/dist/live/install-state.js +6 -2
  28. package/dist/live/js-lockfile-readers.d.ts +8 -1
  29. package/dist/live/js-lockfile-readers.js +43 -6
  30. package/dist/live/lockfile-parents.d.ts +15 -0
  31. package/dist/live/lockfile-parents.js +85 -10
  32. package/dist/live/lockfile-parsers-pygo.d.ts +47 -2
  33. package/dist/live/lockfile-parsers-pygo.js +230 -12
  34. package/dist/live/lockfile-parsers-rbphp.d.ts +38 -0
  35. package/dist/live/lockfile-parsers-rbphp.js +123 -0
  36. package/dist/live/lockfile-parsers.d.ts +6 -0
  37. package/dist/live/lockfile-parsers.js +116 -4
  38. package/dist/live/lockfile-types.d.ts +28 -1
  39. package/dist/live/maintenance.d.ts +20 -4
  40. package/dist/live/maintenance.js +31 -2
  41. package/dist/live/malware.d.ts +20 -0
  42. package/dist/live/malware.js +31 -0
  43. package/dist/live/npm-packument.d.ts +4 -1
  44. package/dist/live/npm-packument.js +5 -2
  45. package/dist/live/offered-release.d.ts +53 -0
  46. package/dist/live/offered-release.js +117 -0
  47. package/dist/live/osv-ranges.d.ts +111 -0
  48. package/dist/live/osv-ranges.js +173 -14
  49. package/dist/live/osv-scanner.d.ts +87 -5
  50. package/dist/live/osv-scanner.js +310 -59
  51. package/dist/live/pinned-advisories.d.ts +2 -2
  52. package/dist/live/pinned-advisories.js +3 -3
  53. package/dist/live/pypi-advice.d.ts +36 -0
  54. package/dist/live/pypi-advice.js +158 -0
  55. package/dist/live/rate-limiter.d.ts +7 -0
  56. package/dist/live/rate-limiter.js +22 -1
  57. package/dist/live/release-metadata.d.ts +5 -0
  58. package/dist/live/resolution.d.ts +3 -0
  59. package/dist/live/resolution.js +6 -3
  60. package/dist/live/scan-coverage.d.ts +26 -0
  61. package/dist/live/scan-coverage.js +70 -0
  62. package/dist/live/semver-utils.d.ts +8 -0
  63. package/dist/live/semver-utils.js +26 -3
  64. package/dist/live/severity-scope.d.ts +26 -1
  65. package/dist/live/severity-scope.js +32 -0
  66. package/dist/live/shell-safe.d.ts +19 -0
  67. package/dist/live/shell-safe.js +36 -0
  68. package/dist/live/types.d.ts +134 -8
  69. package/dist/live/version-compare.d.ts +49 -0
  70. package/dist/live/version-compare.js +131 -0
  71. package/dist/live/withdrawn-releases.d.ts +50 -0
  72. package/dist/live/withdrawn-releases.js +125 -0
  73. package/dist/project-manifests.d.ts +7 -0
  74. package/dist/project-manifests.js +45 -5
  75. package/dist/project-scanner.js +6 -4
  76. package/dist/project-tree.d.ts +56 -0
  77. package/dist/project-tree.js +174 -5
  78. package/dist/schema-registry.d.ts +47 -4
  79. package/dist/schema-registry.js +76 -9
  80. package/dist/server-instructions.js +4 -2
  81. package/dist/setup.d.ts +8 -0
  82. package/dist/setup.js +21 -2
  83. package/dist/tool-dispatch.js +3 -3
  84. package/dist/tools/agent-memory.d.ts +5 -0
  85. package/dist/tools/agent-memory.js +9 -0
  86. package/dist/tools/briefing-task-scope.js +5 -2
  87. package/dist/tools/decision-memory.js +2 -0
  88. package/dist/tools/dependency-check-held.d.ts +44 -0
  89. package/dist/tools/dependency-check-held.js +98 -0
  90. package/dist/tools/dependency-check-signals.d.ts +19 -2
  91. package/dist/tools/dependency-check-signals.js +84 -13
  92. package/dist/tools/dependency-check-versions.d.ts +37 -4
  93. package/dist/tools/dependency-check-versions.js +48 -13
  94. package/dist/tools/dependency-check.d.ts +11 -1
  95. package/dist/tools/dependency-check.js +81 -29
  96. package/dist/tools/dependency-health.js +209 -45
  97. package/dist/tools/get-context.d.ts +3 -0
  98. package/dist/tools/get-context.js +4 -0
  99. package/dist/tools/project-scope.d.ts +8 -0
  100. package/dist/tools/project-scope.js +21 -0
  101. package/dist/tools/upgrade-impact-features.d.ts +37 -0
  102. package/dist/tools/upgrade-impact-features.js +176 -0
  103. package/dist/tools/upgrade-impact-literals.js +26 -4
  104. package/dist/tools/upgrade-impact-report.d.ts +5 -1
  105. package/dist/tools/upgrade-impact-report.js +26 -5
  106. package/dist/tools/upgrade-impact.js +10 -3
  107. package/dist/tools/upgrade-planner-fixpaths.d.ts +162 -5
  108. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  109. package/dist/tools/upgrade-planner.d.ts +59 -4
  110. package/dist/tools/upgrade-planner.js +237 -61
  111. package/dist/tools/vulnerability-scan-concise.d.ts +47 -2
  112. package/dist/tools/vulnerability-scan-concise.js +37 -3
  113. package/dist/tools/vulnerability-scan-format.d.ts +126 -24
  114. package/dist/tools/vulnerability-scan-format.js +193 -26
  115. package/dist/tools/vulnerability-scan.d.ts +224 -63
  116. package/dist/tools/vulnerability-scan.js +50 -15
  117. package/dist/tools/what-should-i-know.d.ts +6 -1
  118. package/dist/tools/what-should-i-know.js +72 -15
  119. package/package.json +2 -2
@@ -249,8 +249,11 @@ export function readPnpmLock(content, versions, instances) {
249
249
  setFirst(name, version);
250
250
  if (!instances)
251
251
  return;
252
+ // A Set, not `transitive.some` per entry: that was O(n²), and a 200k-entry pnpm-lock.yaml
253
+ // held the single-threaded stdio server for 409 s (audit 6.2.5 C-8).
254
+ const seen = new Set(transitive.map(([n, v]) => `${n}${v}`));
252
255
  for (const [name, version] of versions) {
253
- if (!transitive.some(([n, v]) => n === name && v === version))
256
+ if (!seen.has(`${name}${version}`))
254
257
  instances.add(name, version);
255
258
  }
256
259
  for (const [name, version] of transitive)
@@ -265,30 +268,63 @@ export function readPnpmLock(content, versions, instances) {
265
268
  * names the real package in `resolution:`; v1 only in the alias spec.
266
269
  */
267
270
  export function yarnPackageName(block, firstLine, headerName) {
268
- const resolution = /^\s+resolution:\s+"?((?:@[^\s/@"]+\/)?[^\s/@"]+)@npm:/m.exec(block);
271
+ const resolution = yarnBlockField(block, /^[ \t]+resolution:[ \t]+"?((?:@[^\s/@"]+\/)?[^\s/@"]+)@npm:/);
269
272
  if (resolution)
270
- return resolution[1];
273
+ return resolution;
271
274
  const alias = /^"?(?:@[^\s/@"]+\/)?[^\s/@"]+@npm:((?:@[^\s/@"]+\/)?[^\s/@"]+)@/.exec(firstLine);
272
275
  return alias ? alias[1] : headerName;
273
276
  }
277
+ /** Lines longer than this in a yarn.lock are not read: no real entry comes near it. */
278
+ export const MAX_LOCKFILE_LINE = 4096;
279
+ /**
280
+ * The first capture of `lineRe` on any line of a yarn.lock block, or null.
281
+ * Line by line with single-line regexes: the multiline `/^\s+version.../m`
282
+ * forms they replace let `\s+` run across lines and took ~5 s on a hostile
283
+ * 64 KB block (audit 2026-10-12, B-10).
284
+ */
285
+ function yarnBlockField(block, lineRe) {
286
+ for (const line of block.split(/\r?\n/)) {
287
+ if (line.length > MAX_LOCKFILE_LINE)
288
+ continue;
289
+ const m = lineRe.exec(line);
290
+ if (m)
291
+ return m[1];
292
+ }
293
+ return null;
294
+ }
295
+ /** The `version` of a yarn.lock block: v1 ` version "1.2.3"`, berry ` version: 1.2.3`. */
296
+ export function yarnBlockVersion(block) {
297
+ return yarnBlockField(block, /^[ \t]+version:?[ \t]+"?([^"\s]+)"?[ \t]*$/);
298
+ }
274
299
  /**
275
300
  * Reads a yarn lockfile (v1 or berry) into `versions`.
276
301
  * v1: `"@scope/pkg@^1.0.0", "@scope/pkg@^1.1.0":` then ` version "1.2.3"`.
277
302
  * berry: `"@scope/pkg@npm:^1.0.0":` then ` version: 1.2.3`.
278
303
  * Workspace, link, portal, file, exec and patch entries do not name a registry version.
304
+ * Returns how many entries were skipped because a line of theirs is longer
305
+ * than MAX_LOCKFILE_LINE, so the caller can say the read was partial (audit
306
+ * L5: the skip was silent).
279
307
  */
280
308
  export function readYarnLock(content, versions, instances, specs) {
309
+ let skipped = 0;
281
310
  for (const block of content.split(/\r?\n(?=\S)/)) {
282
311
  const firstLine = block.split(/\r?\n/)[0];
312
+ if (firstLine.length > MAX_LOCKFILE_LINE) {
313
+ skipped++;
314
+ continue;
315
+ }
283
316
  const header = /^"?(@?[^@\s"]+)@/.exec(firstLine);
284
317
  if (!header || header[1].startsWith("__"))
285
318
  continue;
286
319
  if (/@(?:workspace|link|portal|file|exec|patch):/.test(firstLine))
287
320
  continue;
288
- const line = /^\s+version:?\s+"?([^"\s]+)"?\s*$/m.exec(block);
289
- const version = line ? exactVersion(line[1]) : null;
290
- if (!version)
321
+ const raw = yarnBlockVersion(block);
322
+ const version = raw ? exactVersion(raw) : null;
323
+ if (!version) {
324
+ if (block.split(/\r?\n/).some((line) => line.length > MAX_LOCKFILE_LINE))
325
+ skipped++;
291
326
  continue;
327
+ }
292
328
  const name = yarnPackageName(block, firstLine, header[1]);
293
329
  // One block per resolved version. Blocks are sorted by spec, so the first
294
330
  // block of a name can be an old nested copy; `specs` lets the caller pick
@@ -302,4 +338,5 @@ export function readYarnLock(content, versions, instances, specs) {
302
338
  }
303
339
  }
304
340
  }
341
+ return skipped;
305
342
  }
@@ -17,6 +17,19 @@ export interface ParentEdge {
17
17
  range: string | null;
18
18
  /** True when the parent is the project itself or a workspace member (then the package is direct there). */
19
19
  local: boolean;
20
+ /**
21
+ * True when the copy ships inside `parent`'s tarball (package-lock `inBundle`): `range` is then the
22
+ * exact version shipped. No refresh moves it and no override reaches it (both checked with npm
23
+ * 10.9.2, 2026-10-15); only a new `parent` release, or replacing `parent`, does (audit E8).
24
+ */
25
+ bundled?: boolean;
26
+ /**
27
+ * For a local edge: the declaring manifest's directory relative to the lockfile's ("." for the
28
+ * project itself, "packages/a" for a workspace member), when the lockfile names it.
29
+ */
30
+ dir?: string;
31
+ /** For a local edge: the manifest section that declares it. */
32
+ section?: "dependencies" | "devDependencies" | "optionalDependencies" | "peerDependencies";
20
33
  }
21
34
  export interface LockfileParents {
22
35
  format: LockfileFormat;
@@ -30,10 +43,12 @@ interface LockEntry {
30
43
  name?: string;
31
44
  version?: string;
32
45
  link?: boolean;
46
+ inBundle?: boolean;
33
47
  requires?: Record<string, string>;
34
48
  dependencies?: Record<string, string> | Record<string, LockEntry>;
35
49
  optionalDependencies?: Record<string, string>;
36
50
  peerDependencies?: Record<string, string>;
51
+ devDependencies?: Record<string, string>;
37
52
  }
38
53
  export declare function packageLockParents(lock: {
39
54
  packages?: Record<string, LockEntry>;
@@ -11,9 +11,16 @@
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { stripTrailingCommas } from "./bun-lockfile.js";
14
- import { exactVersion, parsePnpmKey, pnpmEntryIdentities, pnpmKeyRef, yarnPackageName } from "./js-lockfile-readers.js";
14
+ import { exactVersion, MAX_LOCKFILE_LINE, parsePnpmKey, pnpmEntryIdentities, pnpmKeyRef, yarnBlockVersion, yarnPackageName } from "./js-lockfile-readers.js";
15
15
  import { packageNameFromNodeModulesPath, parseCargoLockPackages } from "./lockfile-parsers.js";
16
16
  const DEP_FIELDS = ["dependencies", "optionalDependencies", "peerDependencies"];
17
+ /**
18
+ * The project's own manifest and each workspace member's also declare devDependencies, which a
19
+ * dependency's lockfile entry never carries. Leaving them out read a dev-only transitive's lever
20
+ * as "no declared dependency above" (navcal: @vitest/mocker and tinypool arrive through the
21
+ * devDependency vitest) and a member's devDependency as undeclared (final release audit, finding 3).
22
+ */
23
+ const LOCAL_DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
17
24
  /** The lockfile format of a path, or null for a file this module does not read. */
18
25
  export function lockfileFormat(file, content) {
19
26
  const base = path.basename(file);
@@ -73,27 +80,72 @@ function resolveInstallPath(packages, from, name) {
73
80
  dir = cut >= 0 ? dir.slice(0, cut) : "";
74
81
  }
75
82
  }
83
+ /**
84
+ * The package a bundled copy at install path `at` ships inside: the nearest enclosing entry that is
85
+ * not itself `inBundle`. A level the lockfile does not list is passed over. Null when none is found.
86
+ */
87
+ function bundleOwner(packages, at) {
88
+ let dir = at;
89
+ for (;;) {
90
+ const cut = dir.lastIndexOf("/node_modules/");
91
+ if (cut < 0)
92
+ return null;
93
+ dir = dir.slice(0, cut);
94
+ const entry = packages[dir];
95
+ if (entry && !entry.inBundle)
96
+ return dir;
97
+ }
98
+ }
99
+ /** A key that installs a DEPENDENCY (under some `node_modules/`), not the root or a workspace member's folder. */
100
+ function isDependencyKey(key) {
101
+ return key.startsWith("node_modules/") || key.includes("/node_modules/");
102
+ }
103
+ /**
104
+ * Whether the entry at `key` is a copy bundled inside a DEPENDENCY's tarball. A project's or workspace
105
+ * member's own `bundleDependencies` also set `inBundle` (Arborist's bundler can be the root), but those
106
+ * copies sit in the user's tree, where `npm update` and overrides reach them like any other.
107
+ */
108
+ function bundledInDependency(packages, key) {
109
+ if (!packages[key]?.inBundle)
110
+ return false;
111
+ const owner = bundleOwner(packages, key);
112
+ return owner !== null && isDependencyKey(owner);
113
+ }
76
114
  export function packageLockParents(lock, name, version) {
77
115
  const edges = [];
78
116
  if (lock.packages && Object.keys(lock.packages).length > 0) {
79
117
  const packages = lock.packages;
118
+ // A bundled copy hangs off its bundle owner, pinned to the version shipped: the ranges the
119
+ // bundle's own packages declare say nothing about what a refresh can reach (audit E8:
120
+ // NodeGoat's debug 4.1.1 inside fsevents read "npm update debug" from needle's ^4.1.0).
121
+ const owners = new Set();
122
+ for (const [key, entry] of Object.entries(packages)) {
123
+ if (entry?.version !== version || packageNameFromNodeModulesPath(key) !== name || !bundledInDependency(packages, key))
124
+ continue;
125
+ const owner = bundleOwner(packages, key);
126
+ if (!owner || owners.has(owner))
127
+ continue;
128
+ owners.add(owner);
129
+ edges.push({ parent: packageNameFromNodeModulesPath(owner), parentVersion: packages[owner]?.version ?? null, range: version, local: false, bundled: true });
130
+ }
80
131
  for (const [key, entry] of Object.entries(packages)) {
81
132
  if (!entry || entry.link)
82
133
  continue;
83
- for (const field of DEP_FIELDS) {
134
+ const local = !key.startsWith("node_modules/") && !key.includes("/node_modules/");
135
+ for (const field of local ? LOCAL_DEP_FIELDS : DEP_FIELDS) {
84
136
  const declared = entry[field];
85
137
  const range = declared?.[name];
86
138
  if (typeof range !== "string")
87
139
  continue;
88
140
  const at = resolveInstallPath(packages, key, name);
89
- if (!at || packages[at]?.version !== version)
141
+ if (!at || packages[at]?.version !== version || bundledInDependency(packages, at))
90
142
  continue;
91
- const local = !key.startsWith("node_modules/") && !key.includes("/node_modules/");
92
143
  edges.push({
93
144
  parent: local ? entry.name ?? (key || lock.name || ".") : packageNameFromNodeModulesPath(key),
94
145
  parentVersion: local ? null : entry.version ?? null,
95
146
  range,
96
147
  local,
148
+ ...(local ? { dir: key === "" ? "." : key, section: field } : {}),
97
149
  });
98
150
  break;
99
151
  }
@@ -215,7 +267,7 @@ export function pnpmParents(content, name, version) {
215
267
  importerSpecifier = null;
216
268
  if (pendingDep && m?.[4]) {
217
269
  if (isTarget(pendingDep, m[4]))
218
- add({ parent: importer, parentVersion: null, range: null, local: true });
270
+ add({ parent: importer, parentVersion: null, range: null, local: true, dir: importer });
219
271
  pendingDep = null;
220
272
  }
221
273
  }
@@ -226,7 +278,7 @@ export function pnpmParents(content, name, version) {
226
278
  const ver = /^version:\s*(.+)$/.exec(text);
227
279
  if (ver) {
228
280
  if (isTarget(pendingDep, ver[1]))
229
- add({ parent: importer, parentVersion: null, range: importerSpecifier, local: true });
281
+ add({ parent: importer, parentVersion: null, range: importerSpecifier, local: true, dir: importer });
230
282
  pendingDep = null;
231
283
  }
232
284
  }
@@ -237,17 +289,40 @@ export function pnpmParents(content, name, version) {
237
289
  // yarn.lock (v1 and berry)
238
290
  // ---------------------------------------------------------------------------
239
291
  const unquote = (s) => s.trim().replace(/^"|"$/g, "");
292
+ /**
293
+ * A yarn dependency line: v1 `name "range"`, berry `name: "npm:range"` or
294
+ * `"@scope/x": range`. The name by regex, the range by hand: the single regex
295
+ * this replaces had a lazy group before `"?\s*$` and was quadratic, ~5 s on a
296
+ * hostile 64 KB line (audit 2026-10-12, B-10). Same results otherwise.
297
+ */
298
+ function yarnDepLine(text) {
299
+ if (text.length > MAX_LOCKFILE_LINE)
300
+ return null;
301
+ const head = /^"?((?:@[^\s"/]+\/)?[^\s":]+)"?:?\s+/.exec(text);
302
+ if (!head)
303
+ return null;
304
+ let range = text.slice(head[0].length).trimEnd();
305
+ if (range.startsWith('"'))
306
+ range = range.slice(1);
307
+ if (range.endsWith('"'))
308
+ range = range.slice(0, -1);
309
+ if (range === "" || range.includes('"'))
310
+ return null;
311
+ return [head[1], range];
312
+ }
240
313
  function parseYarnBlocks(content) {
241
314
  const blocks = [];
242
315
  for (const block of content.split(/\r?\n(?=\S)/)) {
243
316
  const lines = block.split(/\r?\n/);
244
317
  const header = lines[0];
318
+ if (header.length > MAX_LOCKFILE_LINE)
319
+ continue;
245
320
  const headerName = /^"?(@?[^@\s"]+)@/.exec(header)?.[1];
246
321
  if (!headerName || headerName.startsWith("__"))
247
322
  continue;
248
323
  // An npm alias block (`string-width-cjs@npm:string-width@^4.2.0`) installs the real package.
249
324
  const name = yarnPackageName(block, header, headerName);
250
- const version = /^\s+version:?\s+"?([^"\s]+)"?\s*$/m.exec(block)?.[1];
325
+ const version = yarnBlockVersion(block);
251
326
  if (!version)
252
327
  continue;
253
328
  const specs = header.replace(/:\s*$/, "").split(",").map((s) => unquote(s).replace(/@npm:/, "@"));
@@ -266,9 +341,9 @@ function parseYarnBlocks(content) {
266
341
  }
267
342
  if (inDeps && indent > depIndent) {
268
343
  // v1: `name "range"`; berry: `name: "npm:range"` or `"@scope/x": range`.
269
- const m = /^"?((?:@[^\s"/]+\/)?[^\s":]+)"?:?\s+"?([^"]+?)"?\s*$/.exec(text);
270
- if (m)
271
- deps.push([m[1], m[2]]);
344
+ const dep = yarnDepLine(text);
345
+ if (dep)
346
+ deps.push(dep);
272
347
  }
273
348
  else {
274
349
  inDeps = false;
@@ -14,7 +14,7 @@
14
14
  * pseudo-version, so older advisories were missed and later ones invented.
15
15
  * go.sum also lists versions the build never selected.
16
16
  */
17
- import { InstanceSet, type VersionSource } from "./lockfile-types.js";
17
+ import { InstanceSet, type LockfileProblem, type VersionSource } from "./lockfile-types.js";
18
18
  /** PEP 503 normalised name: lowercase, runs of `-`, `_`, `.` become one `-`. */
19
19
  export declare function normalizePythonName(name: string): string;
20
20
  /**
@@ -44,11 +44,56 @@ export declare function resolvePython(cwd: string): VersionSource;
44
44
  * includes (relative to the file) and applying `-c`/`--constraint` files:
45
45
  * a constraints file installs nothing by itself, so its pins only give a
46
46
  * version to a name the requirements list without one. Null when unreadable.
47
+ *
48
+ * Includes are confined to `root`, the project directory (default: the
49
+ * file's own directory). A cloned repository's `-r ../outside/secrets.txt`,
50
+ * an absolute path, or a UNC path (on Windows, opening `\\host\share\x.txt`
51
+ * makes an SMB connection that sends the user's NTLM hash) used to be read,
52
+ * and every `NAME==VALUE` line in it went to OSV.dev and back to the agent.
53
+ * Such an include is skipped; absolute and UNC ones before any fs call.
54
+ * Skipped is not silent: each one goes to `problems` (audit M4), which the
55
+ * scan reports as a file whose pins were not checked.
47
56
  */
48
- export declare function readRequirementsFile(file: string, visited?: Set<string>): Array<{
57
+ export declare function readRequirementsFile(file: string, visited?: Set<string>, root?: string, problems?: LockfileProblem[]): Array<{
49
58
  name: string;
50
59
  version: string;
51
60
  }> | null;
61
+ /**
62
+ * The include's absolute path when `target` is a relative path that stays
63
+ * inside `root` (also after resolving symlinks, when it exists); otherwise
64
+ * null. Absolute, drive-letter and UNC targets are refused before any
65
+ * filesystem call.
66
+ */
67
+ export declare function confinedInclude(dir: string, target: string, root: string): string | null;
68
+ /** `-r file` / `--requirement=file` / `-c file` / `--constraint file` lines (local paths only). */
69
+ export declare function requirementOptions(content: string): Array<{
70
+ option: "r" | "c";
71
+ target: string;
72
+ }>;
73
+ /**
74
+ * A requirements file a resolver wrote: `uv pip compile` or `pip-compile` output. Its source of
75
+ * truth is the input it was compiled from (requirements.in, pyproject.toml), and a hand edit of a
76
+ * pin is reverted by the next compile. Each pin's `# via` annotation says why it is there: an
77
+ * input (`-r requirements.in`, `name (pyproject.toml)`) or only the packages that require it.
78
+ * The server read `# via requests` pins as declared and said "change `idna==2.7` to `idna==3.15`
79
+ * in requirements.txt", which requests 2.19.1 (`idna<2.8`) then refused (audit 6.2.5 A-4).
80
+ */
81
+ export interface CompiledRequirements {
82
+ /** The tool its header names; null when only `# via` annotations show it (a `--no-header` compile). */
83
+ tool: "uv" | "pip-compile" | null;
84
+ /** The single input file the header's command (else the `# via` annotations) names, as written there; null when none or several. */
85
+ input: string | null;
86
+ /** PEP 503 name -> its `# via` entries as written (an entry per required-by package or input). */
87
+ via: Map<string, string[]>;
88
+ }
89
+ export declare function compiledRequirements(content: string): CompiledRequirements | null;
90
+ /**
91
+ * Indirect: compiled in only because other packages require it (every `# via` entry is a package).
92
+ * False for a pin an input names, and for one with no annotation (its reason is unknown).
93
+ */
94
+ export declare function compiledIndirect(compiled: CompiledRequirements, name: string): boolean;
95
+ /** The packages a compiled pin is `# via` (those that require it), PEP 503 normalised. */
96
+ export declare function compiledRequiredBy(compiled: CompiledRequirements, name: string): string[];
52
97
  /**
53
98
  * [[package]] tables from poetry.lock (any version) or uv.lock. A pre-1.2
54
99
  * poetry.lock records `category = "dev"` for dev-only packages; newer files
@@ -53,7 +53,10 @@ export function requirementFiles(cwd) {
53
53
  catch {
54
54
  // no requirements/ folder
55
55
  }
56
- return out;
56
+ // A symlinked requirements-dev.txt or a junctioned requirements/ folder can point anywhere; only
57
+ // `-r` includes were confined, so pins from outside the project reached OSV and the output
58
+ // (audit 6.2.5 C-3). Every file read must resolve inside the project.
59
+ return out.filter((file) => confinedInclude(cwd, path.relative(cwd, file), cwd) !== null);
57
60
  }
58
61
  /** Above this a .txt is not read to decide whether it is a requirements file. */
59
62
  const MAX_SNIFF_BYTES = 256 * 1024;
@@ -139,26 +142,39 @@ export function resolvePython(cwd) {
139
142
  }
140
143
  catch { /* unreadable */ }
141
144
  }
145
+ const collected = [];
142
146
  for (const file of requirementFiles(cwd)) {
143
147
  const dev = devRequirementsFile(file) ? true : undefined;
144
- const pins = readRequirementsFile(file);
148
+ const pins = readRequirementsFile(file, new Set(), cwd, collected);
145
149
  if (pins === null)
146
150
  continue;
147
151
  for (const { name, version } of pins)
148
152
  take(name, version, dev);
149
153
  sources.push(file);
150
154
  }
155
+ // One problem per file, however many requirement files name it.
156
+ const problems = [...new Map(collected.map((p) => [p.file.toLowerCase(), p])).values()];
151
157
  if (sources.length === 0)
152
- return emptySource();
153
- return found(versions, instances, sources[0], lockfile ? "lockfile" : "manifest", { extraSources: sources.slice(1) });
158
+ return problems.length > 0 ? { ...emptySource(), problems } : emptySource();
159
+ const read = found(versions, instances, sources[0], lockfile ? "lockfile" : "manifest", { extraSources: sources.slice(1) });
160
+ return problems.length > 0 ? { ...read, problems } : read;
154
161
  }
155
162
  /**
156
163
  * Exact pins from one requirements file, following `-r`/`--requirement`
157
164
  * includes (relative to the file) and applying `-c`/`--constraint` files:
158
165
  * a constraints file installs nothing by itself, so its pins only give a
159
166
  * version to a name the requirements list without one. Null when unreadable.
167
+ *
168
+ * Includes are confined to `root`, the project directory (default: the
169
+ * file's own directory). A cloned repository's `-r ../outside/secrets.txt`,
170
+ * an absolute path, or a UNC path (on Windows, opening `\\host\share\x.txt`
171
+ * makes an SMB connection that sends the user's NTLM hash) used to be read,
172
+ * and every `NAME==VALUE` line in it went to OSV.dev and back to the agent.
173
+ * Such an include is skipped; absolute and UNC ones before any fs call.
174
+ * Skipped is not silent: each one goes to `problems` (audit M4), which the
175
+ * scan reports as a file whose pins were not checked.
160
176
  */
161
- export function readRequirementsFile(file, visited = new Set()) {
177
+ export function readRequirementsFile(file, visited = new Set(), root = path.dirname(path.resolve(file)), problems = []) {
162
178
  const key = path.resolve(file).toLowerCase();
163
179
  if (visited.has(key))
164
180
  return [];
@@ -172,11 +188,23 @@ export function readRequirementsFile(file, visited = new Set()) {
172
188
  }
173
189
  const dir = path.dirname(file);
174
190
  const out = parseRequirements(content);
191
+ const overlong = overlongRequirementLines(content);
192
+ if (overlong > 0) {
193
+ problems.push({
194
+ file: path.resolve(file),
195
+ skipped: overlong,
196
+ error: `${overlong === 1 ? "1 line" : `${overlong} lines`} longer than ${MAX_REQUIREMENT_LINE} characters (not requirement syntax) ${overlong === 1 ? "was" : "were"} skipped, so any pin on ${overlong === 1 ? "it was" : "them was"} not checked`,
197
+ });
198
+ }
175
199
  const constraints = new Map();
176
200
  for (const { option, target } of requirementOptions(content)) {
177
- const included = path.resolve(dir, target);
201
+ const included = confinedInclude(dir, target, root);
202
+ if (included === null) {
203
+ problems.push(refusedInclude(file, option, target));
204
+ continue;
205
+ }
178
206
  if (option === "r")
179
- out.push(...(readRequirementsFile(included, visited) ?? []));
207
+ out.push(...(readRequirementsFile(included, visited, root, problems) ?? []));
180
208
  else
181
209
  for (const pin of parseRequirements(safeRead(included)))
182
210
  constraints.set(pin.name, pin.version);
@@ -191,6 +219,84 @@ export function readRequirementsFile(file, visited = new Set()) {
191
219
  }
192
220
  return out;
193
221
  }
222
+ /**
223
+ * A refused include as a coverage problem. `file` is the include as it
224
+ * resolves (an absolute, drive-letter or UNC target as written: it is never
225
+ * resolved or touched), so the note names the file whose pins went unchecked.
226
+ */
227
+ function refusedInclude(from, option, target) {
228
+ const absolute = /^[\\/]/.test(target) || /^[A-Za-z]:/.test(target) || path.isAbsolute(target);
229
+ return {
230
+ file: absolute ? target : path.resolve(path.dirname(from), target),
231
+ refused: true,
232
+ error: `\`-${option} ${target}\` in ${path.basename(from)} points outside the project directory, and includes outside it are never read, so the ${option === "r" ? "pins" : "constraints"} in it were not checked`,
233
+ };
234
+ }
235
+ /**
236
+ * The include's absolute path when `target` is a relative path that stays
237
+ * inside `root` (also after resolving symlinks, when it exists); otherwise
238
+ * null. Absolute, drive-letter and UNC targets are refused before any
239
+ * filesystem call.
240
+ */
241
+ export function confinedInclude(dir, target, root) {
242
+ if (/^[\\/]/.test(target) || /^[A-Za-z]:/.test(target) || path.isAbsolute(target))
243
+ return null;
244
+ const within = (base, p) => {
245
+ const rel = path.relative(base, p);
246
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
247
+ };
248
+ const rootDir = path.resolve(root);
249
+ const included = path.resolve(dir, target);
250
+ if (!within(rootDir, included))
251
+ return null;
252
+ let real;
253
+ try {
254
+ real = fs.realpathSync(included);
255
+ }
256
+ catch {
257
+ return included; // missing: nothing to escape through, and the read fails quietly
258
+ }
259
+ try {
260
+ return within(fs.realpathSync(rootDir), real) ? included : null;
261
+ }
262
+ catch {
263
+ return null;
264
+ }
265
+ }
266
+ /** Longer lines are not requirements; capping them keeps the regexes below linear in the file size. */
267
+ const MAX_REQUIREMENT_LINE = 4096;
268
+ /**
269
+ * Logical lines (backslash continuations joined, `--hash` options dropped),
270
+ * with over-long lines left out. The cap used to apply with the hashes in:
271
+ * a `pip-compile --generate-hashes` entry with many wheels (aiohttp 3.8.0:
272
+ * 72 files, 5.7 KB) was dropped silently and never scanned. A hash is not
273
+ * part of the pin, so it goes before the cap; the split is on whitespace,
274
+ * which stays linear on a hostile line.
275
+ */
276
+ function requirementLines(content) {
277
+ return logicalRequirementLines(content).filter((line) => line.length <= MAX_REQUIREMENT_LINE);
278
+ }
279
+ function logicalRequirementLines(content) {
280
+ return content
281
+ .replace(/\\\r?\n/g, " ")
282
+ .split(/\r?\n/)
283
+ .map((line) => (line.includes("--hash") ? withoutHashes(line) : line));
284
+ }
285
+ function withoutHashes(line) {
286
+ const tokens = line.split(/[ \t]+/);
287
+ const kept = [];
288
+ for (let i = 0; i < tokens.length; i++) {
289
+ if (tokens[i] === "--hash")
290
+ i++; // `--hash sha256:...`: the value is the next token
291
+ else if (!tokens[i].startsWith("--hash=") && !tokens[i].startsWith("--hash:"))
292
+ kept.push(tokens[i]);
293
+ }
294
+ return kept.join(" ");
295
+ }
296
+ /** Lines left out for length that are not comments: what a read missed (audit L5's class). */
297
+ function overlongRequirementLines(content) {
298
+ return logicalRequirementLines(content).filter((line) => line.length > MAX_REQUIREMENT_LINE && !line.trimStart().startsWith("#")).length;
299
+ }
194
300
  function safeRead(file) {
195
301
  try {
196
302
  return fs.readFileSync(file, "utf-8");
@@ -200,9 +306,9 @@ function safeRead(file) {
200
306
  }
201
307
  }
202
308
  /** `-r file` / `--requirement=file` / `-c file` / `--constraint file` lines (local paths only). */
203
- function requirementOptions(content) {
309
+ export function requirementOptions(content) {
204
310
  const out = [];
205
- for (const raw of content.replace(/\\\r?\n/g, " ").split(/\r?\n/)) {
311
+ for (const raw of requirementLines(content)) {
206
312
  const m = /^\s*(?:-(r|c)|--(requirement|constraint))(?:\s*=\s*|\s+)(\S+)/.exec(raw);
207
313
  if (!m || /^[a-z][a-z+]*:\/\//i.test(m[3]))
208
314
  continue;
@@ -210,10 +316,120 @@ function requirementOptions(content) {
210
316
  }
211
317
  return out;
212
318
  }
319
+ /** Header lines searched for the tool and its command (uv writes 2, pip-compile 5 to 7). */
320
+ const COMPILED_HEADER_LINES = 12;
321
+ export function compiledRequirements(content) {
322
+ const lines = content.split(/\r?\n/);
323
+ const head = lines.slice(0, COMPILED_HEADER_LINES);
324
+ const tool = head.some((l) => /^#.*autogenerated by uv\b/i.test(l))
325
+ ? "uv"
326
+ : head.some((l) => /^#.*autogenerated by pip-compile\b/i.test(l))
327
+ ? "pip-compile"
328
+ : null;
329
+ const via = viaAnnotations(lines);
330
+ if (tool === null && via.size === 0)
331
+ return null;
332
+ return { tool, input: headerInput(head) ?? viaInput(via), via };
333
+ }
334
+ /** Whether a `# via` entry is a package that requires the pin (not an input file or `-c` constraint). */
335
+ function viaIsPackage(entry) {
336
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[^\]]*\])?$/.test(entry);
337
+ }
338
+ /** The input a `# via` entry names: `-r requirements.in`, or `project (pyproject.toml)`. */
339
+ function viaSource(entry) {
340
+ return /^-r\s+(\S+)$/.exec(entry)?.[1] ?? /^\S+\s+\(([^()\s]+)\)$/.exec(entry)?.[1] ?? null;
341
+ }
342
+ /**
343
+ * Indirect: compiled in only because other packages require it (every `# via` entry is a package).
344
+ * False for a pin an input names, and for one with no annotation (its reason is unknown).
345
+ */
346
+ export function compiledIndirect(compiled, name) {
347
+ const entries = (compiled.via.get(normalizePythonName(name)) ?? []).filter((e) => !/^-c\s/.test(e));
348
+ return entries.length > 0 && entries.every(viaIsPackage);
349
+ }
350
+ /** The packages a compiled pin is `# via` (those that require it), PEP 503 normalised. */
351
+ export function compiledRequiredBy(compiled, name) {
352
+ return (compiled.via.get(normalizePythonName(name)) ?? []).filter(viaIsPackage).map((e) => normalizePythonName(e.replace(/\[.*$/, "")));
353
+ }
354
+ /**
355
+ * `# via` annotations per pin, in both styles the tools write: split (pip-tools 6+, uv)
356
+ * idna==2.7
357
+ * # via requests or # via
358
+ * # requests
359
+ * # -r requirements.in
360
+ * and pip-tools 5's inline `idna==2.7 # via requests, urllib3`.
361
+ */
362
+ function viaAnnotations(lines) {
363
+ const out = new Map();
364
+ let current = null;
365
+ let listing = false;
366
+ const add = (entry) => {
367
+ const e = entry.trim();
368
+ if (current && e)
369
+ out.set(current, [...(out.get(current) ?? []), e]);
370
+ };
371
+ for (const raw of lines) {
372
+ if (raw.length > MAX_REQUIREMENT_LINE)
373
+ continue;
374
+ const line = raw.trim();
375
+ if (!line.startsWith("#")) {
376
+ listing = false;
377
+ if (line.startsWith("--hash") || line === "" || line.startsWith("-"))
378
+ continue; // a hash continuation or an option keeps the pin
379
+ const pin = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*===?/.exec(line);
380
+ current = pin ? normalizePythonName(pin[1]) : null;
381
+ const inline = /\s#\s*via\s+(.+)$/.exec(line);
382
+ if (current && inline)
383
+ for (const e of inline[1].split(","))
384
+ add(e);
385
+ continue;
386
+ }
387
+ const via = /^#\s*via\b\s*(.*)$/.exec(line);
388
+ if (via) {
389
+ listing = via[1].trim() === "";
390
+ if (!listing)
391
+ add(via[1]);
392
+ }
393
+ else if (listing && /^#\s{2,}\S/.test(line)) {
394
+ add(line.replace(/^#\s+/, ""));
395
+ }
396
+ else {
397
+ listing = false;
398
+ }
399
+ }
400
+ return out;
401
+ }
402
+ /** Input-file names a compile command takes: requirements.in / *.txt, pyproject.toml, setup.cfg, setup.py. */
403
+ const COMPILE_INPUT = /(?:\.in|\.txt|(?:^|\/)pyproject\.toml|(?:^|\/)setup\.cfg|(?:^|\/)setup\.py)$/i;
404
+ /** Options whose value is a file but not the input (output, constraints, overrides). */
405
+ const COMPILE_FILE_OPTIONS = new Set(["-o", "--output-file", "-c", "--constraint", "--constraints", "--override", "--overrides", "-b", "--build-constraint", "--build-constraints"]);
406
+ /** The one input file the header's command line names (uv: after "via the following command:"; pip-compile: "by the following command:" / "To update, run:"). */
407
+ function headerInput(head) {
408
+ const at = head.findIndex((l) => /following command:|to update, run:/i.test(l));
409
+ if (at < 0)
410
+ return null;
411
+ const command = head.slice(at + 1).map((l) => l.replace(/^#\s*/, "").trim()).find((l) => l.length > 0);
412
+ if (!command)
413
+ return null;
414
+ const tokens = command.split(/\s+/);
415
+ const inputs = tokens.filter((t, i) => !t.startsWith("-") && COMPILE_INPUT.test(t) && !COMPILE_FILE_OPTIONS.has(tokens[i - 1] ?? ""));
416
+ return inputs.length === 1 ? inputs[0] : null;
417
+ }
418
+ /** The one input the `# via` annotations name (`-r requirements.in`, `(pyproject.toml)`), when they name exactly one. */
419
+ function viaInput(via) {
420
+ const sources = new Set();
421
+ for (const entries of via.values())
422
+ for (const e of entries) {
423
+ const s = viaSource(e);
424
+ if (s)
425
+ sources.add(s);
426
+ }
427
+ return sources.size === 1 ? [...sources][0] : null;
428
+ }
213
429
  /** Names a requirements file lists with no exact pin ("flask", "flask>=2"), PEP 503 normalised. */
214
430
  function unpinnedRequirementNames(content) {
215
431
  const out = [];
216
- for (const raw of content.replace(/\\\r?\n/g, " ").split(/\r?\n/)) {
432
+ for (const raw of requirementLines(content)) {
217
433
  const line = raw.replace(/(^|\s)#.*$/, "").trim();
218
434
  if (!line || line.startsWith("-"))
219
435
  continue;
@@ -278,8 +494,10 @@ export function readTomlPackageLock(content) {
278
494
  */
279
495
  export function parseRequirements(content) {
280
496
  const out = [];
281
- // Backslash continuations join physical lines into one requirement.
282
- const logical = content.replace(/\\\r?\n/g, " ").split(/\r?\n/);
497
+ // Backslash continuations join physical lines into one requirement; a line
498
+ // over 4 KB is no requirement and is dropped before any regex sees it (a
499
+ // 64 KB line took ~5 s in the pin regex, audit 2026-10-12 B-10).
500
+ const logical = requirementLines(content);
283
501
  for (const raw of logical) {
284
502
  // A `#` starts a comment at line start or after whitespace (PEP 508 URLs may contain `#`).
285
503
  const line = raw.replace(/(^|\s)#.*$/, "").trim();