penthera 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/README.md +139 -76
  3. package/SECURITY.md +8 -6
  4. package/docs/examples/pr-dependency-review.yml +1 -1
  5. package/lib/adaptive-probes.js +3 -3
  6. package/lib/advisories/evidence.js +248 -0
  7. package/lib/advisories/index.js +228 -0
  8. package/lib/advisories/products.js +83 -0
  9. package/lib/advisories/versions.js +65 -0
  10. package/lib/agents/hidden-content.js +122 -11
  11. package/lib/agents/index.js +26 -6
  12. package/lib/agents/lock.js +33 -5
  13. package/lib/agents/rules.js +33 -4
  14. package/lib/compliance/index.js +4 -4
  15. package/lib/crawler.js +12 -15
  16. package/lib/deps/capabilities.js +21 -5
  17. package/lib/deps/findings.js +309 -0
  18. package/lib/deps/index.js +13 -272
  19. package/lib/deps/iocs.js +74 -0
  20. package/lib/deps/lockfile.js +3 -8
  21. package/lib/deps/online.js +8 -3
  22. package/lib/deps/popular-packages.json.gz +0 -0
  23. package/lib/deps/popular-pypi.json.gz +0 -0
  24. package/lib/deps/python/capabilities.js +16 -3
  25. package/lib/deps/python/index.js +5 -1
  26. package/lib/deps/python/lockfiles.js +11 -5
  27. package/lib/deps/python/source.js +75 -26
  28. package/lib/deps/python/toml.js +15 -3
  29. package/lib/deps/source.js +28 -9
  30. package/lib/deps/typosquat.js +30 -5
  31. package/lib/fix/apply.js +253 -34
  32. package/lib/fix/index.js +7 -4
  33. package/lib/fix/model.js +25 -5
  34. package/lib/fix/util.js +6 -2
  35. package/lib/fuzzer.js +3 -3
  36. package/lib/git.js +65 -0
  37. package/lib/hook/index.js +157 -73
  38. package/lib/hook/policy.js +10 -4
  39. package/lib/install/index.js +4 -7
  40. package/lib/machine.js +0 -13
  41. package/lib/memory/store.js +11 -4
  42. package/lib/nuclei-loader.js +0 -25
  43. package/lib/owasp-wstg.js +4 -0
  44. package/lib/plugins.js +1 -1
  45. package/lib/provenance.js +1 -0
  46. package/lib/recon.js +12 -31
  47. package/lib/references.json +224 -0
  48. package/lib/retirejs.js +4 -4
  49. package/lib/rules/advisories/advisories.json +186 -0
  50. package/lib/rules/advisories/eol.json +413 -0
  51. package/lib/rules/advisories/pack.json +20 -0
  52. package/lib/rules/advisories/pack.json.sig +6 -0
  53. package/lib/rules/allowed_signers +13 -0
  54. package/lib/rules/core/agent-exec.json +29 -0
  55. package/lib/rules/core/agent-instructions.json +17 -0
  56. package/lib/rules/core/pack.json +30 -0
  57. package/lib/rules/core/pack.json.sig +6 -0
  58. package/lib/rules/core/secrets.json +161 -0
  59. package/lib/rules/core/templates.json +52 -0
  60. package/lib/rules/index.js +107 -0
  61. package/lib/rules/ioc-2025-2026/files.json +43 -0
  62. package/lib/rules/ioc-2025-2026/hosts.json +35 -0
  63. package/lib/rules/ioc-2025-2026/pack.json +25 -0
  64. package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
  65. package/lib/rules/ioc-2025-2026/packages.json +1048 -0
  66. package/lib/rules/pack.js +452 -0
  67. package/lib/rules/regex-guard.js +81 -9
  68. package/lib/rules/sshsig.js +205 -0
  69. package/lib/templates.js +24 -20
  70. package/lib/tmp.js +21 -0
  71. package/lib/whitebox/frameworks.js +1 -1
  72. package/lib/whitebox/secrets.js +117 -41
  73. package/package.json +12 -4
  74. package/scripts/rules/build-pack.mjs +80 -0
  75. package/scripts/rules/check-bundled.mjs +30 -0
  76. package/scripts/rules/sign-pack.sh +32 -0
  77. package/skills/penthera/SKILL.md +16 -15
  78. package/src/agent/git.js +147 -14
  79. package/src/agent/loop.js +165 -46
  80. package/src/cli/agent.js +11 -5
  81. package/src/cli/deps.js +0 -2
  82. package/src/cli/detect.js +14 -7
  83. package/src/cli/fix.js +26 -8
  84. package/src/cli/hook.js +2 -1
  85. package/src/cli/onboarding.js +14 -6
  86. package/src/cli/report-outputs.js +1 -0
  87. package/src/cli/rules.js +144 -0
  88. package/src/cli/ui.js +43 -10
  89. package/src/cli.js +24 -1
  90. package/src/report/evidence.js +105 -21
  91. package/src/report/footer.js +1 -1
  92. package/src/report/md-escape.js +8 -0
  93. package/src/scan-repo.js +6 -0
  94. package/src/scan-url.js +11 -3
  95. package/src/ui/api/data.js +39 -6
  96. package/src/ui/api/routes.js +44 -11
  97. package/src/ui/api/runs.js +32 -7
  98. package/src/ui/security.js +20 -23
  99. package/src/ui/server.js +52 -27
  100. package/src/ui/static/app.css +583 -213
  101. package/src/ui/static/favicon.svg +1 -1
  102. package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
  103. package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
  104. package/src/ui/static/index.html +5 -4
  105. package/src/ui/static/js/api.js +114 -6
  106. package/src/ui/static/js/app.js +81 -42
  107. package/src/ui/static/js/center.js +246 -133
  108. package/src/ui/static/js/charts.js +99 -18
  109. package/src/ui/static/js/copy.js +68 -9
  110. package/src/ui/static/js/dialog.js +152 -0
  111. package/src/ui/static/js/left.js +23 -60
  112. package/src/ui/static/js/menubar.js +170 -0
  113. package/src/ui/static/js/onboarding.js +104 -0
  114. package/src/ui/static/js/panels.js +186 -0
  115. package/src/ui/static/js/right.js +69 -188
  116. package/src/ui/static/js/state.js +36 -5
  117. package/src/ui/static/logo.svg +10 -0
  118. package/src/ui/static/mark.svg +3 -0
  119. package/src/utils/http.js +162 -13
  120. package/docs/METHODOLOGY.md +0 -375
  121. package/docs/commands/README.md +0 -24
  122. package/docs/commands/action.md +0 -21
  123. package/docs/commands/agents.md +0 -38
  124. package/docs/commands/deps.md +0 -115
  125. package/docs/commands/fix.md +0 -53
  126. package/docs/commands/hook.md +0 -69
  127. package/docs/commands/init.md +0 -114
  128. package/docs/commands/install.md +0 -53
  129. package/docs/commands/scan.md +0 -100
  130. package/docs/commands/skill.md +0 -58
  131. package/docs/compare/README.md +0 -25
  132. package/docs/compare/github.md +0 -60
  133. package/docs/compare/semgrep.md +0 -54
  134. package/docs/compare/snyk.md +0 -62
  135. package/docs/compare/socket.md +0 -78
  136. package/docs/compare/zap.md +0 -59
  137. package/docs/compliance-cra.md +0 -141
  138. package/docs/compliance-nis2.md +0 -168
  139. package/docs/dashboard.md +0 -187
  140. package/docs/evidence.md +0 -162
  141. package/docs/funding/due-diligence-checklist.md +0 -79
  142. package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
  143. package/docs/funding/partner-one-pager.md +0 -84
  144. package/docs/funding/sponsor-tiers.md +0 -81
  145. package/docs/index.md +0 -81
  146. package/docs/internal-audit.md +0 -83
  147. package/docs/local-loop.md +0 -276
  148. package/docs/marketing.md +0 -151
  149. package/docs/owasp-wstg-coverage.md +0 -79
  150. package/docs/plan.md +0 -102
  151. package/docs/privacy.md +0 -38
  152. package/docs/references.bib +0 -559
  153. package/docs/references.md +0 -119
  154. package/docs/release-plan.md +0 -228
  155. package/docs/research/socket-positioning-notes.md +0 -183
  156. package/docs/use-cases/README.md +0 -14
  157. package/docs/use-cases/agencies.md +0 -71
  158. package/docs/use-cases/ai-coding-teams.md +0 -81
  159. package/docs/use-cases/eu-sme-compliance.md +0 -68
  160. package/docs/use-cases/open-source-maintainers.md +0 -75
  161. package/docs/use-cases/solo-founder.md +0 -65
  162. package/lib/deps/popular-packages.json +0 -1
  163. package/lib/deps/popular-pypi.json +0 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Indicators of compromise from rule packs (lib/rules), for `penthera deps`.
3
+ *
4
+ * ioc-package known-malicious name@version, matched against the lockfile,
5
+ * so it works for packages that are not installed
6
+ * ioc-host exfiltration domains and endpoint URLs, matched as literal
7
+ * strings in package code and package.json
8
+ * ioc-file files a campaign drops into packages, by path and
9
+ * optionally SHA-256
10
+ *
11
+ * Literal matching only, no regexes: a pack cannot make this slow.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { rulesOf } from "../rules/index.js";
15
+
16
+ /** Build the active IOC set once per scan. Returns null when no pack has IOCs. */
17
+ export function loadIocs() {
18
+ const pkgRules = rulesOf("ioc-package");
19
+ const hostRules = rulesOf("ioc-host");
20
+ const fileRules = rulesOf("ioc-file");
21
+ if (!pkgRules.length && !hostRules.length && !fileRules.length) return null;
22
+
23
+ const byName = new Map();
24
+ for (const r of pkgRules) {
25
+ for (const p of r.packages) {
26
+ const list = byName.get(p.name) || [];
27
+ list.push({ rule: r, versions: new Set(p.versions) });
28
+ byName.set(p.name, list);
29
+ }
30
+ }
31
+ const needles = [];
32
+ for (const r of hostRules) {
33
+ for (const h of r.hosts) needles.push({ rule: r, value: h, type: "host" });
34
+ for (const u of r.urls) needles.push({ rule: r, value: u, type: "url" });
35
+ }
36
+ const files = fileRules.flatMap((r) => r.files.map((f) => ({ rule: r, ...f })));
37
+
38
+ return {
39
+ /** Lockfile entries that match a known-malicious version. */
40
+ matchPackage(name, version) {
41
+ return (byName.get(name) || [])
42
+ .filter((e) => e.versions.has(version) || e.versions.has("*"))
43
+ .map((e) => ({ type: "package", value: `${name}@${version}`, rule: e.rule }));
44
+ },
45
+ /** Host and URL indicators in one file's text. */
46
+ scanText(text, file) {
47
+ const hits = [];
48
+ for (const n of needles) {
49
+ const idx = text.indexOf(n.value);
50
+ if (idx < 0) continue;
51
+ // A host must stand on its own: evil.example should not fire on notevil.example.
52
+ if (n.type === "host" && /[a-z0-9-]/i.test(text[idx - 1] || "")) continue;
53
+ hits.push({ type: n.type, value: n.value, rule: n.rule, file, line: text.slice(0, idx).split("\n").length });
54
+ }
55
+ return hits;
56
+ },
57
+ /** Dropped-file indicators in a package's file list. */
58
+ scanFiles(list, source, prefix) {
59
+ const hits = [];
60
+ for (const f of files) {
61
+ const found = list.filter((p) => p === f.path || p.endsWith(`/${f.path}`));
62
+ for (const p of found) {
63
+ if (f.sha256) {
64
+ let digest;
65
+ try { digest = createHash("sha256").update(source.read(p)).digest("hex"); } catch { continue; }
66
+ if (digest !== f.sha256) continue;
67
+ }
68
+ hits.push({ type: "file", value: f.sha256 ? `${f.path} (sha256 ${f.sha256.slice(0, 12)}…)` : f.path, rule: f.rule, file: `${prefix}${p}`, line: 1 });
69
+ }
70
+ }
71
+ return hits;
72
+ },
73
+ };
74
+ }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { readFileSync, existsSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { execFileSync } from "node:child_process";
11
+ import { git as safeGit } from "../git.js";
12
12
 
13
13
  /** Package name from a lockfile v2/v3 key like node_modules/a/node_modules/@s/b. */
14
14
  export function nameFromLockPath(key) {
@@ -105,14 +105,9 @@ export function assertSafeRef(ref) {
105
105
  return ref;
106
106
  }
107
107
 
108
- /**
109
- * git for reading a scanned repo: no fsmonitor, no hooks, no transports.
110
- * Read-only plumbing only (rev-parse, show, ls-tree).
111
- */
108
+ /** git for reading a scanned repo (lib/git.js). Read-only plumbing only (rev-parse, show, ls-tree). */
112
109
  export function readOnlyGit(dir) {
113
- return (args) => execFileSync("git", ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", ...args], {
114
- cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, env: { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" },
115
- });
110
+ return (args) => safeGit(dir, args);
116
111
  }
117
112
 
118
113
  /**
@@ -14,7 +14,12 @@
14
14
  * - version deprecated.
15
15
  */
16
16
  import { sourceFromTarball } from "./source.js";
17
- import { request } from "../../src/utils/http.js";
17
+ import { readBody, readJson, request } from "../../src/utils/http.js";
18
+
19
+ // Packuments of popular packages run to tens of MB; tarballs are capped by the
20
+ // unpacked-size limit in source.js anyway.
21
+ const MAX_PACKUMENT_BYTES = 128 * 1024 * 1024;
22
+ const MAX_TARBALL_BYTES = 256 * 1024 * 1024;
18
23
 
19
24
  // Registry calls go through the shared client: same User-Agent, same egress
20
25
  // accounting (and refusal under --offline) as every other request.
@@ -35,7 +40,7 @@ export async function fetchPackument(name, { fetchImpl = registryFetch, registry
35
40
  try {
36
41
  const res = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeout) });
37
42
  if (!res.ok) return null;
38
- return await res.json();
43
+ return await readJson(res, { maxBytes: MAX_PACKUMENT_BYTES, timeout });
39
44
  } catch {
40
45
  return null;
41
46
  }
@@ -124,7 +129,7 @@ export async function fetchTarballSource(url, integrity, { fetchImpl = registryF
124
129
  try {
125
130
  const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeout) });
126
131
  if (!res.ok) return null;
127
- const buf = Buffer.from(await res.arrayBuffer());
132
+ const buf = await readBody(res, { maxBytes: MAX_TARBALL_BYTES, timeout, binary: true });
128
133
  return sourceFromTarball(buf, integrity, "registry");
129
134
  } catch {
130
135
  return null;
Binary file
@@ -21,7 +21,8 @@ import {
21
21
  } from "../capabilities.js";
22
22
 
23
23
  const j = (...parts) => parts.join("");
24
- const MAX_PY_BYTES = 8 * 1024 * 1024;
24
+ // As for npm: a file over this is not read, and the package is then unscanned.
25
+ const MAX_PY_BYTES = 64 * 1024 * 1024;
25
26
  const EXFIL_WINDOW = 40;
26
27
  const NEAR_WINDOW = 30;
27
28
  const PTH_WINDOW = 5;
@@ -318,9 +319,10 @@ const NATIVE_EXT = /\.(?:so(?:\.\d+)*|pyd|dylib|dll)$/i;
318
319
  /**
319
320
  * Scan a Python package through a source (lib/deps/source.js interface; a
320
321
  * source may also expose recordHashes: Map<rel, "sha256=...">).
321
- * @param {{ files(): string[], read(rel): Buffer, size(rel): number, recordHashes?: Map }} source
322
+ * @param {{ files(): string[], read(rel): Buffer, size(rel): number, recordHashes?: Map, anomaly?: string, truncated?: boolean }} source
322
323
  * @param {{ prefix?: string }} opts
323
- * @returns same shape as scanPackage() for npm, plus startupHooks and modified
324
+ * @returns same shape as scanPackage() for npm (including unscanned: why the
325
+ * package could not be fully read, else null), plus startupHooks and modified
324
326
  */
325
327
  export function scanPythonPackage(source, opts = {}) {
326
328
  const prefix = opts.prefix || "";
@@ -360,6 +362,16 @@ export function scanPythonPackage(source, opts = {}) {
360
362
  if (r.exfil) exfil.push(r.exfil);
361
363
  }
362
364
 
365
+ // Fail closed, as for npm: a package whose files were not all read is
366
+ // unscanned, never clean. Every real wheel, sdist or installed distribution
367
+ // has at least its metadata files, so an empty list means nothing was read.
368
+ let unscanned = source.anomaly || null;
369
+ if (!unscanned && files.length === 0) unscanned = "no files could be read for this package";
370
+ if (!unscanned && source.truncated) unscanned = `the package has more than ${source.maxFiles} files, so some were never read`;
371
+ if (!unscanned && skipped.length) {
372
+ unscanned = `${skipped.length} code file(s) over ${MAX_PY_BYTES / 1024 / 1024} MB were not scanned (${skipped.slice(0, 3).join(", ")}${skipped.length > 3 ? ", ..." : ""})`;
373
+ }
374
+
363
375
  // Module name -> file, for resolving what a .pth or setup.py hook runs.
364
376
  const moduleFile = (mod) => [`${mod}.py`, `${mod}/__init__.py`].find((f) => fileSet.has(f)) || null;
365
377
  const riskyOf = (f) => (f && fileCaps[f] ? fileCaps[f].filter((c) => RISKY_FOR_INSTALL.includes(c) || c === "shell") : []);
@@ -441,6 +453,7 @@ export function scanPythonPackage(source, opts = {}) {
441
453
  exfil,
442
454
  filesScanned,
443
455
  skipped,
456
+ unscanned,
444
457
  startupHooks,
445
458
  modified,
446
459
  };
@@ -104,11 +104,15 @@ function scanPyTree(pkgs, env, ctx) {
104
104
  ctx.cache.set(key, src ? { ...scanPythonPackage(src, { prefix }), source: src.kind } : null);
105
105
  }
106
106
  const r = ctx.cache.get(key);
107
+ // Fail closed: a package that could not be fully read has unknown
108
+ // capabilities (null), not an empty set.
109
+ const unscanned = r?.unscanned || null;
107
110
  return {
108
111
  ...p,
109
112
  version,
110
113
  source: r?.source || null,
111
- capabilities: r ? r.capabilities : null,
114
+ capabilities: r && !unscanned ? r.capabilities : null,
115
+ unscanned,
112
116
  installScripts: r?.installScripts || {},
113
117
  installRisk: r?.installRisk || [],
114
118
  exfil: r?.exfil || [],
@@ -298,11 +298,17 @@ function parsePipfileLock(text, dir) {
298
298
  */
299
299
  export function parsePythonInput(text, kind, dir, file = kind) {
300
300
  let r;
301
- if (kind === "uv.lock") r = parseUvLock(text, dir);
302
- else if (kind === "poetry.lock") r = parsePoetryLock(text, dir);
303
- else if (kind === "pdm.lock") r = parsePdmLock(text, dir);
304
- else if (kind === "Pipfile.lock") r = parsePipfileLock(text, dir);
305
- else r = { ...parseRequirements(text, { file, baseDir: dir }), version: null };
301
+ try {
302
+ if (kind === "uv.lock") r = parseUvLock(text, dir);
303
+ else if (kind === "poetry.lock") r = parsePoetryLock(text, dir);
304
+ else if (kind === "pdm.lock") r = parsePdmLock(text, dir);
305
+ else if (kind === "Pipfile.lock") r = parsePipfileLock(text, dir);
306
+ else r = { ...parseRequirements(text, { file, baseDir: dir }), version: null };
307
+ } catch (e) {
308
+ // One clear error naming the file, whatever the parser hit (malformed or
309
+ // hostile input): the tree cannot be read, so nothing is reported clean.
310
+ throw new Error(`${file} could not be parsed: ${e.message}`);
311
+ }
306
312
  // One entry per name@version (requirements files can repeat a name across -r includes).
307
313
  const seen = new Set();
308
314
  const packages = r.packages.filter((p) => {
@@ -20,11 +20,10 @@ import { join, relative, resolve, sep } from "node:path";
20
20
  import { createHash } from "node:crypto";
21
21
  import { inflateRawSync, gunzipSync } from "node:zlib";
22
22
  import { homedir } from "node:os";
23
- import { DirSource, MapSource, untar } from "../source.js";
23
+ import { DirSource, MapSource, untar, MAX_FILES } from "../source.js";
24
24
  import { normalizeName } from "./lockfiles.js";
25
25
  import { recordHash } from "./capabilities.js";
26
26
 
27
- const MAX_FILES = 5000;
28
27
  // Not part of a local package's source: environments, caches, build output.
29
28
  const LOCAL_SKIP = new Set([".venv", "venv", ".tox", ".nox", "__pycache__", ".mypy_cache", ".pytest_cache", "build", "dist", ".eggs"]);
30
29
  const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
@@ -112,7 +111,10 @@ export class RecordSource {
112
111
  const root = resolve(siteDir);
113
112
  // Only files inside site-packages: console-script wrappers in bin/ are generated by pip.
114
113
  const ok = entries.filter((e) => e.path && !e.path.split(/[\\/]/).includes("..") && resolve(root, e.path).startsWith(root + sep));
115
- this._files = [...new Set(ok.map((e) => e.path.replace(/\\/g, "/")))].sort().slice(0, MAX_FILES);
114
+ const all = [...new Set(ok.map((e) => e.path.replace(/\\/g, "/")))].sort();
115
+ this.maxFiles = MAX_FILES;
116
+ this.truncated = all.length > MAX_FILES;
117
+ this._files = all.slice(0, MAX_FILES);
116
118
  this.recordHashes = new Map(ok.filter((e) => e.hash).map((e) => [e.path.replace(/\\/g, "/"), e.hash]));
117
119
  }
118
120
 
@@ -155,12 +157,18 @@ export function listInstalled(siteDir) {
155
157
  .map((p) => ({ path: relative(siteDir, resolve(siteDir, e, p)).split(sep).join("/"), hash: null }));
156
158
  } catch { /* no file list */ }
157
159
  }
160
+ let guessTruncated = false;
158
161
  if (!record.length) {
159
162
  // No file list: fall back to the folder named after the distribution.
160
163
  const guess = md.Name.replace(/-/g, "_");
161
- if (existsSync(join(siteDir, guess))) record = new DirSource(join(siteDir, guess)).files().map((f) => ({ path: `${guess}/${f}`, hash: null }));
164
+ if (existsSync(join(siteDir, guess))) {
165
+ const dir = new DirSource(join(siteDir, guess));
166
+ record = dir.files().map((f) => ({ path: `${guess}/${f}`, hash: null }));
167
+ guessTruncated = dir.truncated;
168
+ }
162
169
  }
163
170
  const src = new RecordSource(siteDir, record);
171
+ if (guessTruncated) src.truncated = true;
164
172
  for (const f of src.files()) owned.add(f);
165
173
  out.push({ name: normalizeName(md.Name), displayName: md.Name, version: md.Version || null, distInfo: e, source: src });
166
174
  }
@@ -170,23 +178,44 @@ export function listInstalled(siteDir) {
170
178
 
171
179
  // ── Archives ──────────────────────────────────────────────────────────────
172
180
 
181
+ /** A wheel that could not be parsed unambiguously. Callers fail closed. */
182
+ export class ZipError extends Error {
183
+ constructor(message) {
184
+ super(message);
185
+ this.name = "ZipError";
186
+ }
187
+ }
188
+
173
189
  /**
174
190
  * Minimal zip reader for wheels: central directory, stored and deflated
175
- * entries. Paths that climb out are dropped; total size is capped.
176
- * @returns {Map<string, Buffer>}
191
+ * entries. Paths that climb out are dropped. Returns Map<path, Buffer>.
192
+ *
193
+ * Fails closed. The sizes in the central directory are the archive's own
194
+ * claim, so each entry is checked against the bytes really there, and the
195
+ * total is capped on what was actually inflated. A bad record, an entry that
196
+ * does not fit the archive, a size that does not match its data, encryption,
197
+ * ZIP64 or an unknown compression method throws ZipError rather than being
198
+ * skipped: whatever comes after it would otherwise go unread.
177
199
  */
178
- export function unzip(buf) {
200
+ export function unzip(buf, { maxTotal = MAX_UNPACKED_BYTES, maxEntries = MAX_FILES } = {}) {
179
201
  const files = new Map();
180
202
  let eocd = -1;
181
203
  for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) {
182
204
  if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
183
205
  }
184
- if (eocd === -1) throw new Error("not a zip file");
206
+ if (eocd === -1) throw new ZipError("not a zip file");
185
207
  const count = buf.readUInt16LE(eocd + 10);
208
+ const cdSize = buf.readUInt32LE(eocd + 12);
186
209
  let off = buf.readUInt32LE(eocd + 16);
210
+ if (count === 0xffff || cdSize === 0xffffffff || off === 0xffffffff) throw new ZipError("ZIP64 archives are not supported");
211
+ if (buf.readUInt16LE(eocd + 4) !== 0 || buf.readUInt16LE(eocd + 8) !== count) throw new ZipError("multi-disk archive");
212
+ if (count > maxEntries) throw new ZipError(`more than ${maxEntries} entries`);
213
+ const cdEnd = off + cdSize;
214
+ if (cdEnd > eocd) throw new ZipError("central directory outside the archive");
187
215
  let total = 0;
188
- for (let n = 0; n < count && off + 46 <= buf.length; n++) {
189
- if (buf.readUInt32LE(off) !== 0x02014b50) break;
216
+ for (let n = 0; n < count; n++) {
217
+ if (off + 46 > cdEnd || buf.readUInt32LE(off) !== 0x02014b50) throw new ZipError(`bad central directory record ${n}`);
218
+ const flags = buf.readUInt16LE(off + 8);
190
219
  const method = buf.readUInt16LE(off + 10);
191
220
  const csize = buf.readUInt32LE(off + 20);
192
221
  const usize = buf.readUInt32LE(off + 24);
@@ -194,30 +223,51 @@ export function unzip(buf) {
194
223
  const extraLen = buf.readUInt16LE(off + 30);
195
224
  const commentLen = buf.readUInt16LE(off + 32);
196
225
  const local = buf.readUInt32LE(off + 42);
226
+ if (off + 46 + nameLen + extraLen + commentLen > cdEnd) throw new ZipError(`central directory record ${n} overruns`);
197
227
  const name = buf.subarray(off + 46, off + 46 + nameLen).toString("utf-8");
228
+ const label = JSON.stringify(name);
198
229
  off += 46 + nameLen + extraLen + commentLen;
199
- if (name.endsWith("/") || files.size >= MAX_FILES) continue;
200
- const parts = name.split("/").filter(Boolean);
201
- if (parts.includes("..") || name.startsWith("/")) continue;
202
- if (buf.readUInt32LE(local) !== 0x04034b50) continue;
230
+ if (csize === 0xffffffff || usize === 0xffffffff || local === 0xffffffff) throw new ZipError("ZIP64 entries are not supported");
231
+ if (flags & 1) throw new ZipError(`encrypted entry ${label}`);
232
+ if (local + 30 > buf.length || buf.readUInt32LE(local) !== 0x04034b50) throw new ZipError(`bad local header for ${label}`);
203
233
  const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
234
+ if (start + csize > buf.length) throw new ZipError(`entry ${label} runs past the end of the archive`);
235
+ if (name.endsWith("/")) continue;
204
236
  const raw = buf.subarray(start, start + csize);
205
- total += usize;
206
- if (total > MAX_UNPACKED_BYTES) break;
207
237
  let data;
208
238
  if (method === 0) data = Buffer.from(raw);
209
- else if (method === 8) data = inflateRawSync(raw, { maxOutputLength: Math.max(usize, 1) });
210
- else continue;
239
+ else if (method === 8) {
240
+ try {
241
+ // Capped on what really comes out, not on the size the entry claims.
242
+ data = inflateRawSync(raw, { maxOutputLength: Math.max(1, maxTotal - total) });
243
+ } catch (e) {
244
+ throw new ZipError(`entry ${label} could not be inflated within ${maxTotal} bytes (${e.code || e.message})`);
245
+ }
246
+ } else throw new ZipError(`unsupported compression method ${method} for ${label}`);
247
+ if (data.length !== usize) throw new ZipError(`entry ${label} holds ${data.length} bytes, the archive says ${usize}`);
248
+ total += data.length;
249
+ if (total > maxTotal) throw new ZipError(`unpacked size over ${maxTotal} bytes`);
250
+ const parts = name.split("/").filter(Boolean);
251
+ if (parts.includes("..") || name.startsWith("/")) continue;
211
252
  files.set(parts.join("/"), data);
212
253
  }
213
254
  return files;
214
255
  }
215
256
 
216
- /** Unpack a wheel (zip) or sdist (.tar.gz) into a MapSource. */
257
+ /**
258
+ * Unpack a wheel (zip) or sdist (.tar.gz) into a MapSource. The bytes are an
259
+ * artifact the caller chose to scan (matched by hash, or a local path), so one
260
+ * that cannot be read safely is not "missing": it comes back as a source with
261
+ * an anomaly and no files, and the scan fails closed.
262
+ */
217
263
  export function sourceFromArtifact(bytes, kind) {
218
- if (bytes.readUInt32LE(0) === 0x04034b50) return new MapSource(unzip(bytes), kind);
219
- if (bytes[0] === 0x1f && bytes[1] === 0x8b) return new MapSource(untar(gunzipSync(bytes, { maxOutputLength: MAX_UNPACKED_BYTES })), kind);
220
- return null;
264
+ try {
265
+ if (bytes.length >= 4 && bytes.readUInt32LE(0) === 0x04034b50) return new MapSource(unzip(bytes), kind);
266
+ if (bytes[0] === 0x1f && bytes[1] === 0x8b) return new MapSource(untar(gunzipSync(bytes, { maxOutputLength: MAX_UNPACKED_BYTES })), kind);
267
+ } catch (e) {
268
+ return new MapSource(new Map(), kind, { anomaly: `archive could not be read safely (${e.message})` });
269
+ }
270
+ return new MapSource(new Map(), kind, { anomaly: "archive is neither a wheel nor a gzipped sdist" });
221
271
  }
222
272
 
223
273
  // ── pip's HTTP cache ──────────────────────────────────────────────────────
@@ -281,10 +331,9 @@ export function sourceFromPipCache(pkg, dir = pipCacheDir()) {
281
331
  for (const bytes of hits) {
282
332
  // Re-verify: the cache may have changed since it was indexed.
283
333
  if (!wanted.includes(createHash("sha256").update(bytes).digest("hex"))) continue;
284
- try {
285
- const src = sourceFromArtifact(bytes, "pip-cache");
286
- if (src) return src;
287
- } catch { /* corrupt artifact */ }
334
+ // These are the bytes the lockfile pins. If they do not unpack cleanly the
335
+ // source carries an anomaly, so the package cannot pass as merely uncached.
336
+ return sourceFromArtifact(bytes, "pip-cache");
288
337
  }
289
338
  return null;
290
339
  }
@@ -5,13 +5,23 @@
5
5
  * tables, arrays of tables, dotted keys, basic/literal/multi-line strings,
6
6
  * integers, floats, booleans, dates (kept as strings), multi-line arrays and
7
7
  * inline tables. Not a validating parser: malformed input yields a best-effort
8
- * object or throws, never runs anything. Linear in the input size.
8
+ * object or throws, never runs anything. Linear in the input size. Arrays
9
+ * and inline tables nest at most MAX_DEPTH deep, so hostile input throws a
10
+ * TOML error instead of overflowing the stack.
9
11
  */
10
12
 
13
+ const MAX_DEPTH = 200;
14
+
11
15
  class Reader {
12
16
  constructor(src) {
13
17
  this.s = src.replace(/\r\n/g, "\n");
14
18
  this.i = 0;
19
+ this.depth = 0;
20
+ }
21
+
22
+ /** Enter a nested array or inline table. */
23
+ nest() {
24
+ if (++this.depth > MAX_DEPTH) this.error(`nested deeper than ${MAX_DEPTH} levels`);
15
25
  }
16
26
 
17
27
  peek(n = 0) { return this.s[this.i + n]; }
@@ -123,11 +133,12 @@ class Reader {
123
133
  }
124
134
 
125
135
  array() {
136
+ this.nest();
126
137
  this.i++;
127
138
  const out = [];
128
139
  for (;;) {
129
140
  this.ws(true);
130
- if (this.peek() === "]") { this.i++; return out; }
141
+ if (this.peek() === "]") { this.i++; this.depth--; return out; }
131
142
  if (this.eof()) this.error("unterminated array");
132
143
  out.push(this.value());
133
144
  this.ws(true);
@@ -137,11 +148,12 @@ class Reader {
137
148
  }
138
149
 
139
150
  inlineTable() {
151
+ this.nest();
140
152
  this.i++;
141
153
  const out = {};
142
154
  for (;;) {
143
155
  this.ws(true);
144
- if (this.peek() === "}") { this.i++; return out; }
156
+ if (this.peek() === "}") { this.i++; this.depth--; return out; }
145
157
  if (this.eof()) this.error("unterminated inline table");
146
158
  const k = this.dottedKey();
147
159
  this.ws();
@@ -14,14 +14,22 @@ import { createHash } from "node:crypto";
14
14
  import { gunzipSync } from "node:zlib";
15
15
  import { homedir } from "node:os";
16
16
 
17
- const MAX_FILES = 5000;
17
+ /**
18
+ * Most files a package may have. The same as the tarball entry cap, so any
19
+ * tarball the reader accepts is read whole. A source with more sets
20
+ * `truncated`, and the scan then fails closed (see scanPackage): files past
21
+ * the cap were never read, so the package cannot be called clean.
22
+ */
23
+ export const MAX_FILES = 50_000;
18
24
 
19
25
  /** Files of an installed package folder. Skips nested node_modules and symlinks. */
20
26
  export class DirSource {
21
- constructor(dir, { skip } = {}) {
27
+ constructor(dir, { skip, maxFiles = MAX_FILES } = {}) {
22
28
  this.dir = dir;
23
29
  this.kind = "installed";
24
30
  this.skip = skip || null;
31
+ this.maxFiles = maxFiles;
32
+ this.truncated = false;
25
33
  }
26
34
 
27
35
  files() {
@@ -32,13 +40,14 @@ export class DirSource {
32
40
  try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
33
41
  entries.sort((a, b) => a.name.localeCompare(b.name));
34
42
  for (const e of entries) {
35
- if (out.length >= MAX_FILES) return;
43
+ if (this.truncated) return;
36
44
  const p = join(d, e.name);
37
45
  if (e.isSymbolicLink()) continue;
38
46
  if (e.isDirectory()) {
39
47
  if (e.name === "node_modules" || e.name === ".git" || this.skip?.has(e.name)) continue;
40
48
  walk(p);
41
49
  } else if (e.isFile()) {
50
+ if (out.length >= this.maxFiles) { this.truncated = true; return; }
42
51
  out.push(relative(this.dir, p).split(sep).join("/"));
43
52
  }
44
53
  }
@@ -63,15 +72,17 @@ export class DirSource {
63
72
  * scanPackage). `oversize` maps files too large to hold to their real size.
64
73
  */
65
74
  export class MapSource {
66
- constructor(map, kind = "npm-cache", { anomaly = null } = {}) {
75
+ constructor(map, kind = "npm-cache", { anomaly = null, maxFiles = MAX_FILES } = {}) {
67
76
  this.map = map;
68
77
  this.kind = kind;
69
78
  this.anomaly = anomaly;
70
79
  this.oversize = map.oversize || new Map();
80
+ this.truncated = map.size > maxFiles;
81
+ this.maxFiles = maxFiles;
71
82
  }
72
83
 
73
84
  files() {
74
- return [...this.map.keys()].sort().slice(0, MAX_FILES);
85
+ return [...this.map.keys()].sort().slice(0, this.maxFiles);
75
86
  }
76
87
 
77
88
  size(rel) {
@@ -159,8 +170,10 @@ function parsePax(body) {
159
170
  * are listed in map.oversize (path -> size) instead of being copied.
160
171
  *
161
172
  * Fails closed: a bad checksum, a malformed or impossible size, a truncated
162
- * entry, or data after the end-of-archive marker throws TarError, so a crafted
163
- * archive can never show this reader different files than npm extracts.
173
+ * entry, data after the end-of-archive marker, or a pax global header that
174
+ * sets size, path or linkpath throws TarError. These are the ways known to
175
+ * make this reader frame entries differently from npm (node-tar); this is a
176
+ * small reader, not a proof that the two can never disagree.
164
177
  */
165
178
  export function untar(buf, { maxTotal = MAX_UNPACKED_BYTES, maxFile = MAX_FILE_BYTES } = {}) {
166
179
  const files = new Map();
@@ -200,8 +213,14 @@ export function untar(buf, { maxTotal = MAX_UNPACKED_BYTES, maxFile = MAX_FILE_B
200
213
  pax = parsePax(body);
201
214
  continue;
202
215
  }
203
- if (type === "g") { // pax global header: validated, not used
204
- parsePax(body);
216
+ if (type === "g") {
217
+ // pax global header. node-tar applies its keys to every later entry; this
218
+ // reader does not, so one that changes how entries are framed or named
219
+ // is refused rather than read differently from npm.
220
+ const g = parsePax(body);
221
+ for (const k of ["size", "path", "linkpath"]) {
222
+ if (Object.hasOwn(g, k)) throw new TarError(`pax global header sets ${k}`);
223
+ }
205
224
  continue;
206
225
  }
207
226
  if (type === "L") { // GNU long name
@@ -2,7 +2,7 @@
2
2
  * Typosquat and slopsquat checks for npm and PyPI package names (`penthera deps`).
3
3
  *
4
4
  * Compares each dependency name against a vendored list of popular package
5
- * names (lib/deps/popular-packages.json, refreshed at release time by
5
+ * names (lib/deps/popular-packages.json.gz, refreshed at release time by
6
6
  * scripts/refresh-popular-packages.mjs; never fetched at runtime). A name that
7
7
  * is one or two edits away from a popular package, but is not itself popular,
8
8
  * is worth a second look: that is how `crossenv` (for cross-env) and
@@ -16,20 +16,44 @@
16
16
  * near-miss check, plus (with --online) a very young publish date.
17
17
  */
18
18
  import { readFileSync } from "node:fs";
19
+ import { gunzipSync } from "node:zlib";
20
+ import { rulesOf, rulesGeneration } from "../rules/index.js";
19
21
 
20
22
  const _popular = {};
21
- // PyPI's list is lib/deps/popular-pypi.json (scripts/refresh-popular-pypi.mjs).
22
- const LISTS = { npm: "./popular-packages.json", pypi: "./popular-pypi.json" };
23
+ // PyPI's list is lib/deps/popular-pypi.json.gz (scripts/refresh-popular-pypi.mjs).
24
+ // Both are gzipped JSON: they ship in the npm package, and gzip keeps that
25
+ // within the release size budget without changing what is checked.
26
+ const LISTS = { npm: "./popular-packages.json.gz", pypi: "./popular-pypi.json.gz" };
23
27
 
24
28
  /** The vendored popular-name list for an ecosystem: { source, names: string[] }. */
25
29
  export function loadPopular(ecosystem = "npm") {
26
30
  if (!_popular[ecosystem]) {
27
- const data = JSON.parse(readFileSync(new URL(LISTS[ecosystem], import.meta.url), "utf-8"));
31
+ const data = JSON.parse(gunzipSync(readFileSync(new URL(LISTS[ecosystem], import.meta.url))).toString("utf-8"));
28
32
  _popular[ecosystem] = { ...data, ecosystem, set: new Set(data.names), rank: new Map(data.names.map((n, i) => [n, i + 1])) };
29
33
  }
30
34
  return _popular[ecosystem];
31
35
  }
32
36
 
37
+ let _merged = null;
38
+ let _mergedGen = -1;
39
+ /**
40
+ * The popular list plus `typosquat-target` names from rule packs, ranked after
41
+ * the vendored list in pack order. Without pack names this is loadPopular().
42
+ */
43
+ export function loadTargets() {
44
+ const base = loadPopular();
45
+ if (_mergedGen === rulesGeneration()) return _merged;
46
+ const extra = rulesOf("typosquat-target").flatMap((r) => r.names).filter((n) => !base.set.has(n));
47
+ if (extra.length === 0) {
48
+ _merged = base;
49
+ } else {
50
+ const names = [...base.names, ...new Set(extra)];
51
+ _merged = { ...base, names, set: new Set(names), rank: new Map(names.map((n, i) => [n, i + 1])) };
52
+ }
53
+ _mergedGen = rulesGeneration();
54
+ return _merged;
55
+ }
56
+
33
57
  /** Damerau-Levenshtein (optimal string alignment) distance, capped at max+1. */
34
58
  export function editDistance(a, b, max = 2) {
35
59
  if (a === b) return 0;
@@ -114,7 +138,8 @@ export function checkName(name, popular = loadPopular()) {
114
138
  * @param {"npm"|"pypi"} ecosystem
115
139
  */
116
140
  export function findTyposquats(names, ecosystem = "npm") {
117
- const popular = loadPopular(ecosystem);
141
+ // Pack typosquat targets are npm names.
142
+ const popular = ecosystem === "npm" ? loadTargets() : loadPopular(ecosystem);
118
143
  const out = [];
119
144
  for (const name of [...new Set(names)].sort()) {
120
145
  const hit = checkName(name, popular);