penthera 1.5.0 → 1.6.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 (104) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +128 -663
  3. package/bin/scan.js +19 -0
  4. package/docs/commands/README.md +23 -0
  5. package/docs/commands/action.md +21 -0
  6. package/docs/commands/agents.md +38 -0
  7. package/docs/commands/deps.md +115 -0
  8. package/docs/commands/fix.md +53 -0
  9. package/docs/commands/hook.md +69 -0
  10. package/docs/commands/install.md +53 -0
  11. package/docs/commands/scan.md +100 -0
  12. package/docs/commands/skill.md +58 -0
  13. package/docs/compare/README.md +25 -0
  14. package/docs/compare/github.md +60 -0
  15. package/docs/compare/semgrep.md +54 -0
  16. package/docs/compare/snyk.md +62 -0
  17. package/docs/compare/socket.md +78 -0
  18. package/docs/compare/zap.md +59 -0
  19. package/docs/dashboard.md +244 -0
  20. package/docs/evidence.md +8 -4
  21. package/docs/examples/github-actions-deps.yml +4 -4
  22. package/docs/examples/github-actions-scan.yml +4 -4
  23. package/docs/index.md +9 -0
  24. package/docs/internal-audit.md +83 -0
  25. package/docs/marketing.md +151 -0
  26. package/docs/plan.md +4 -3
  27. package/docs/privacy.md +38 -0
  28. package/docs/references.bib +10 -0
  29. package/docs/references.md +1 -0
  30. package/docs/research/socket-positioning-notes.md +183 -0
  31. package/docs/use-cases/README.md +14 -0
  32. package/docs/use-cases/agencies.md +71 -0
  33. package/docs/use-cases/ai-coding-teams.md +81 -0
  34. package/docs/use-cases/eu-sme-compliance.md +68 -0
  35. package/docs/use-cases/open-source-maintainers.md +75 -0
  36. package/docs/use-cases/solo-founder.md +65 -0
  37. package/lib/blackbox/email-dns.js +5 -0
  38. package/lib/compliance/questionnaire.js +4 -2
  39. package/lib/deps/capabilities.js +49 -12
  40. package/lib/deps/index.js +290 -30
  41. package/lib/deps/lockfile.js +25 -2
  42. package/lib/deps/popular-pypi.json +1 -0
  43. package/lib/deps/python/capabilities.js +447 -0
  44. package/lib/deps/python/index.js +234 -0
  45. package/lib/deps/python/lockfiles.js +359 -0
  46. package/lib/deps/python/source.js +396 -0
  47. package/lib/deps/python/toml.js +219 -0
  48. package/lib/deps/report.js +37 -6
  49. package/lib/deps/source.js +149 -24
  50. package/lib/deps/typosquat.js +24 -13
  51. package/lib/fix/apply.js +83 -19
  52. package/lib/hook/index.js +41 -8
  53. package/lib/install/index.js +59 -18
  54. package/lib/install/policy.js +17 -5
  55. package/lib/memory/repo-suppress.js +11 -3
  56. package/lib/references.json +77 -0
  57. package/lib/remediation/index.js +9 -0
  58. package/lib/retirejs.js +69 -17
  59. package/lib/rules/regex-guard.js +368 -0
  60. package/lib/templates.js +13 -6
  61. package/lib/tls.js +7 -0
  62. package/lib/whitebox/frameworks.js +8 -5
  63. package/lib/whitebox/secrets.js +20 -7
  64. package/package.json +7 -3
  65. package/skills/penthera/SKILL.md +23 -5
  66. package/src/cli/deps.js +60 -5
  67. package/src/cli/evidence.js +14 -4
  68. package/src/cli/install.js +14 -5
  69. package/src/cli/report-outputs.js +25 -2
  70. package/src/cli/ui.js +89 -0
  71. package/src/cli.js +19 -7
  72. package/src/report/audience.js +10 -10
  73. package/src/report/evidence.js +33 -14
  74. package/src/report/footer.js +11 -0
  75. package/src/report/markdown.js +6 -3
  76. package/src/report/md-escape.js +16 -0
  77. package/src/reporter.js +1 -0
  78. package/src/scan-url.js +5 -1
  79. package/src/ui/api/data.js +309 -0
  80. package/src/ui/api/demo.js +464 -0
  81. package/src/ui/api/routes.js +134 -0
  82. package/src/ui/api/runs.js +243 -0
  83. package/src/ui/api/summarize.js +451 -0
  84. package/src/ui/security.js +153 -0
  85. package/src/ui/server.js +186 -0
  86. package/src/ui/static/app.css +723 -0
  87. package/src/ui/static/favicon.svg +1 -0
  88. package/src/ui/static/index.html +57 -0
  89. package/src/ui/static/js/api.js +29 -0
  90. package/src/ui/static/js/app.js +138 -0
  91. package/src/ui/static/js/charts.js +207 -0
  92. package/src/ui/static/js/copy.js +79 -0
  93. package/src/ui/static/js/dom.js +120 -0
  94. package/src/ui/static/js/format.js +42 -0
  95. package/src/ui/static/js/theme.js +10 -0
  96. package/src/ui/static/js/typer.js +99 -0
  97. package/src/ui/static/js/views/ai.js +68 -0
  98. package/src/ui/static/js/views/deps.js +96 -0
  99. package/src/ui/static/js/views/evidence.js +115 -0
  100. package/src/ui/static/js/views/findings.js +165 -0
  101. package/src/ui/static/js/views/history.js +92 -0
  102. package/src/ui/static/js/views/overview.js +184 -0
  103. package/src/ui/static/js/views/run.js +349 -0
  104. package/src/ui/static/js/views/settings.js +67 -0
@@ -3,10 +3,11 @@
3
3
  * the full report, and a short PR comment centred on the capability diff.
4
4
  */
5
5
  import { CAPABILITIES, CAPABILITY_ORDER } from "./capabilities.js";
6
+ import { FOOTER } from "../../src/report/footer.js";
6
7
 
7
8
  const plain = (s) => s;
8
9
  const label = (id) => CAPABILITIES[id]?.label || id;
9
- const RISKY = new Set(["install-scripts", "shell", "network", "fs-write", "env-bulk", "eval", "obfuscated", "ai-config-write", "ai-cli-invoke", "token-harvest"]);
10
+ const RISKY = new Set(["install-scripts", "shell", "network", "fs-write", "env-bulk", "eval", "obfuscated", "ai-config-write", "ai-cli-invoke", "token-harvest", "startup-hook"]);
10
11
 
11
12
  function countsLine(counts) {
12
13
  return CAPABILITY_ORDER.filter((id) => counts[id]).map((id) => `${label(id)} ${counts[id]}`).join(" · ");
@@ -19,10 +20,11 @@ export function formatDepsTerminal(deps, c = {}, opts = {}) {
19
20
  const k = { bold: plain, dim: plain, red: plain, yellow: plain, green: plain, cyan: plain, ...c };
20
21
  const L = [];
21
22
  const missing = deps.notInstalled.length;
22
- L.push(` Packages ${k.bold(String(deps.total))} in ${deps.lockfile} ${k.dim(`(v${deps.lockfileVersion}${deps.prod ? ", prod only" : ""}, ${deps.network})`)}`);
23
+ L.push(` Packages ${k.bold(String(deps.total))} in ${deps.lockfile} ${k.dim(`(${deps.format || `v${deps.lockfileVersion}`}${deps.prod ? ", prod only" : ""}, ${deps.network})`)}`);
23
24
  L.push(` Scanned ${deps.scanned}${missing ? k.yellow(` · ${missing} not installed or cached`) : ""}${deps.skippedOptional ? k.dim(` · ${deps.skippedOptional} optional for other platforms`) : ""}`);
24
25
  const cl = countsLine(deps.capabilityCounts);
25
26
  if (cl) L.push(` Can do ${k.dim(cl)}`);
27
+ if (deps.online?.note) L.push(` Online ${k.dim(deps.online.note)}`);
26
28
  if (deps.typosquats.length) L.push(` Names ${k.yellow(`${deps.typosquats.length} look like popular packages`)}`);
27
29
  if (deps.licenses) L.push(` Licenses ${licenseLine(deps.licenses, k)}`);
28
30
 
@@ -68,8 +70,36 @@ export function formatDepsTerminal(deps, c = {}, opts = {}) {
68
70
  return L;
69
71
  }
70
72
 
71
- const esc = (s) => String(s ?? "").replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\n/g, " ");
72
- const code = (s) => "`" + String(s).replace(/`/g, "ˋ") + "`";
73
+ // Package names, versions, paths and snippets come from the lockfile and the
74
+ // package files, so in a PR comment they are attacker-controlled text.
75
+ const MAX_FIELD = 200;
76
+ const ZW = "\u200b"; // zero-width space: breaks mentions, refs and autolinks invisibly
77
+ const cap = (s) => {
78
+ const t = String(s ?? "").replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ");
79
+ return t.length > MAX_FIELD ? `${t.slice(0, MAX_FIELD - 1)}…` : t;
80
+ };
81
+ /** Neutralise GitHub's text-level features: @mentions, #refs and bare-URL autolinks. */
82
+ const defuse = (t) => t
83
+ .replace(/@/g, `@${ZW}`)
84
+ .replace(/#/g, `#${ZW}`)
85
+ .replace(/:\/\//g, `:${ZW}//`)
86
+ .replace(/\bwww\./gi, (m) => `${m.slice(0, 3)}${ZW}.`);
87
+
88
+ /** Plain text safe inside Markdown (tables included): no HTML, links, images, emphasis, mentions. */
89
+ export function esc(s) {
90
+ const t = cap(s)
91
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
92
+ .replace(/[\\`*_{}[\]()!|~]/g, (c) => `\\${c}`);
93
+ return defuse(t);
94
+ }
95
+
96
+ /**
97
+ * An inline code span: one line, no backticks, no table pipes, no angle
98
+ * brackets (an HTML tag that starts before a code span outranks it), capped.
99
+ */
100
+ export function code(s) {
101
+ return "`" + defuse(cap(s).replace(/`/g, "ˋ").replace(/\|/g, "¦").replace(/</g, "‹").replace(/>/g, "›")) + "`";
102
+ }
73
103
 
74
104
  /** Capability diff as Markdown (used in the full report and the PR comment). */
75
105
  export function formatDiffMarkdown(deps) {
@@ -105,7 +135,7 @@ export function formatDiffMarkdown(deps) {
105
135
  }
106
136
  const unavailable = d.upgraded.filter((u) => u.unavailable);
107
137
  if (unavailable.length) {
108
- L.push(`> Not compared, old version not available offline: ${unavailable.map((u) => `${esc(u.name)} ${esc(u.from)}`).join(", ")}. Install the base tree and pass it to \`--diff\`, or use \`--online\`.`);
138
+ L.push(`> Not compared, old version not available offline: ${unavailable.map((u) => `${esc(u.name)} ${esc(u.from)}`).join(", ")}. Install the base tree and pass it to \`--diff\`${deps.ecosystem === "pypi" ? "" : ", or use \`--online\`"}.`);
109
139
  L.push("");
110
140
  }
111
141
  const withEvidence = [...gained.map((u) => [`${u.name}@${u.to}`, u.gained]), ...added.map((a) => [`${a.name}@${a.version}`, a.capabilities.filter((id) => RISKY.has(id))])]
@@ -144,7 +174,7 @@ export function formatDepsMarkdown(deps) {
144
174
  const L = [];
145
175
  L.push("## Dependencies");
146
176
  L.push("");
147
- L.push(`**${deps.total}** packages in \`${esc(deps.lockfile)}\` (lockfile v${deps.lockfileVersion}${deps.prod ? ", production only" : ""}), ${deps.scanned} scanned ${deps.network}.${deps.notInstalled.length ? ` ${deps.notInstalled.length} not installed or cached.` : ""}`);
177
+ L.push(`**${deps.total}** packages in \`${esc(deps.lockfile)}\` (${deps.format ? esc(deps.format) : `lockfile v${deps.lockfileVersion}`}${deps.prod ? ", production only" : ""}), ${deps.scanned} scanned ${deps.network}.${deps.notInstalled.length ? ` ${deps.notInstalled.length} not installed or cached.` : ""}`);
148
178
  L.push("");
149
179
  const rows = CAPABILITY_ORDER.filter((id) => deps.capabilityCounts[id]);
150
180
  if (rows.length) {
@@ -186,5 +216,6 @@ export function formatPrComment(result, opts = {}) {
186
216
  L.push("");
187
217
  }
188
218
  L.push(`<sub>[penthera deps](https://github.com/danoszz/penthera)${opts.version ? ` v${opts.version}` : ""}: offline static analysis, no telemetry. Capabilities are what code can do, not a verdict.</sub>`);
219
+ if (opts.footer !== false) L.push("", FOOTER);
189
220
  return L.join("\n") + "\n";
190
221
  }
@@ -18,9 +18,10 @@ const MAX_FILES = 5000;
18
18
 
19
19
  /** Files of an installed package folder. Skips nested node_modules and symlinks. */
20
20
  export class DirSource {
21
- constructor(dir) {
21
+ constructor(dir, { skip } = {}) {
22
22
  this.dir = dir;
23
23
  this.kind = "installed";
24
+ this.skip = skip || null;
24
25
  }
25
26
 
26
27
  files() {
@@ -35,7 +36,7 @@ export class DirSource {
35
36
  const p = join(d, e.name);
36
37
  if (e.isSymbolicLink()) continue;
37
38
  if (e.isDirectory()) {
38
- if (e.name === "node_modules" || e.name === ".git") continue;
39
+ if (e.name === "node_modules" || e.name === ".git" || this.skip?.has(e.name)) continue;
39
40
  walk(p);
40
41
  } else if (e.isFile()) {
41
42
  out.push(relative(this.dir, p).split(sep).join("/"));
@@ -56,11 +57,17 @@ export class DirSource {
56
57
  }
57
58
  }
58
59
 
59
- /** Files of an unpacked tarball, held in memory. */
60
+ /**
61
+ * Files of an unpacked tarball, held in memory. `anomaly` is set when the
62
+ * archive could not be read safely (the scan then fails closed, see
63
+ * scanPackage). `oversize` maps files too large to hold to their real size.
64
+ */
60
65
  export class MapSource {
61
- constructor(map, kind = "npm-cache") {
66
+ constructor(map, kind = "npm-cache", { anomaly = null } = {}) {
62
67
  this.map = map;
63
68
  this.kind = kind;
69
+ this.anomaly = anomaly;
70
+ this.oversize = map.oversize || new Map();
64
71
  }
65
72
 
66
73
  files() {
@@ -68,7 +75,7 @@ export class MapSource {
68
75
  }
69
76
 
70
77
  size(rel) {
71
- return this.map.get(rel)?.length || 0;
78
+ return this.oversize.get(rel) ?? (this.map.get(rel)?.length || 0);
72
79
  }
73
80
 
74
81
  read(rel) {
@@ -78,6 +85,19 @@ export class MapSource {
78
85
 
79
86
  // ── Tarballs ──────────────────────────────────────────────────────────────
80
87
 
88
+ /** Unpacked size limits, enforced while reading, not afterwards. */
89
+ export const MAX_UNPACKED_BYTES = 256 * 1024 * 1024;
90
+ export const MAX_FILE_BYTES = 64 * 1024 * 1024;
91
+ const MAX_ENTRIES = 50_000;
92
+
93
+ /** A tarball that could not be parsed unambiguously. Callers fail closed. */
94
+ export class TarError extends Error {
95
+ constructor(message) {
96
+ super(message);
97
+ this.name = "TarError";
98
+ }
99
+ }
100
+
81
101
  function cstr(buf, start, len) {
82
102
  const s = buf.subarray(start, start + len);
83
103
  const z = s.indexOf(0);
@@ -85,45 +105,141 @@ function cstr(buf, start, len) {
85
105
  }
86
106
 
87
107
  /**
88
- * Minimal tar reader (ustar + pax/GNU long names). Returns Map<path, Buffer>
89
- * with the leading "package/" directory stripped. Only regular files are kept;
90
- * paths that try to climb out ("..") are dropped.
108
+ * A numeric header field: octal (NUL or space terminated), or GNU base-256
109
+ * (high bit of the first byte set). Anything else is an error, never 0: a size
110
+ * read wrong desyncs the parser from what npm (node-tar) extracts.
91
111
  */
92
- export function untar(buf) {
112
+ export function tarNumber(header, start, len, field) {
113
+ const f = header.subarray(start, start + len);
114
+ if (f[0] & 0x80) {
115
+ if (f[0] === 0xff) throw new TarError(`negative base-256 ${field}`);
116
+ let n = BigInt(f[0] & 0x7f);
117
+ for (let i = 1; i < f.length; i++) n = (n << 8n) | BigInt(f[i]);
118
+ if (n > BigInt(Number.MAX_SAFE_INTEGER)) throw new TarError(`${field} too large`);
119
+ return Number(n);
120
+ }
121
+ const s = f.toString("latin1").replace(/[\0 ]+$/, "").replace(/^ +/, "");
122
+ if (s === "") return 0;
123
+ if (!/^[0-7]+$/.test(s)) throw new TarError(`malformed ${field} ${JSON.stringify(s)}`);
124
+ return parseInt(s, 8);
125
+ }
126
+
127
+ function checksumOk(header) {
128
+ const want = tarNumber(header, 148, 8, "checksum");
129
+ let sum = 0;
130
+ for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : header[i];
131
+ return sum === want;
132
+ }
133
+
134
+ /** pax extended header records ("<len> <key>=<value>\n"). */
135
+ function parsePax(body) {
136
+ const out = {};
137
+ let i = 0;
138
+ while (i < body.length) {
139
+ if (body[i] === 0) break; // NUL padding after the last record
140
+ const sp = body.indexOf(0x20, i);
141
+ if (sp === -1) throw new TarError("malformed pax header");
142
+ const len = Number(body.subarray(i, sp).toString("latin1"));
143
+ if (!Number.isInteger(len) || len <= sp - i || i + len > body.length || body[i + len - 1] !== 0x0a) {
144
+ throw new TarError("malformed pax record");
145
+ }
146
+ const rec = body.subarray(sp + 1, i + len - 1).toString("utf-8");
147
+ const eq = rec.indexOf("=");
148
+ if (eq === -1) throw new TarError("malformed pax record");
149
+ out[rec.slice(0, eq)] = rec.slice(eq + 1);
150
+ i += len;
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * Tar reader (ustar, pax and GNU extensions). Returns Map<path, Buffer> with
157
+ * the leading "package/" directory stripped. Only regular files are kept;
158
+ * paths that try to climb out ("..") are dropped. Files over MAX_FILE_BYTES
159
+ * are listed in map.oversize (path -> size) instead of being copied.
160
+ *
161
+ * 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.
164
+ */
165
+ export function untar(buf, { maxTotal = MAX_UNPACKED_BYTES, maxFile = MAX_FILE_BYTES } = {}) {
93
166
  const files = new Map();
167
+ files.oversize = new Map();
94
168
  let off = 0;
169
+ let pax = {};
95
170
  let longName = null;
171
+ let total = 0;
172
+ let entries = 0;
96
173
  while (off + 512 <= buf.length) {
97
174
  const header = buf.subarray(off, off + 512);
98
- if (header.every((b) => b === 0)) break;
99
- let name = cstr(header, 0, 100);
100
- const size = parseInt(cstr(header, 124, 12).trim() || "0", 8) || 0;
175
+ if (header.every((b) => b === 0)) {
176
+ // End of archive. Everything after it must be zero padding too.
177
+ for (let i = off; i < buf.length; i++) if (buf[i] !== 0) throw new TarError(`data after the end-of-archive marker at byte ${off}`);
178
+ return files;
179
+ }
180
+ if (++entries > MAX_ENTRIES) throw new TarError(`more than ${MAX_ENTRIES} entries`);
181
+ if (!checksumOk(header)) throw new TarError(`bad header checksum at byte ${off}`);
101
182
  const type = String.fromCharCode(header[156] || 48);
102
- const prefix = cstr(header, 345, 155);
183
+ let size = tarNumber(header, 124, 12, "size");
184
+ if (pax.size != null && type !== "x" && type !== "g" && type !== "L" && type !== "K") {
185
+ if (!/^\d+$/.test(pax.size)) throw new TarError(`malformed pax size ${JSON.stringify(pax.size)}`);
186
+ size = Number(pax.size);
187
+ }
188
+ if (size > maxTotal) throw new TarError(`entry larger than ${maxTotal} bytes`);
189
+ if (off + 512 + size > buf.length) throw new TarError(`truncated entry at byte ${off}`);
190
+ total += size;
191
+ if (total > maxTotal) throw new TarError(`unpacked size over ${maxTotal} bytes`);
192
+ let name = cstr(header, 0, 100);
193
+ const ustar = header.subarray(257, 263).toString("latin1") === "ustar\0";
194
+ const prefix = ustar ? cstr(header, 345, 155) : "";
103
195
  if (prefix) name = `${prefix}/${name}`;
104
196
  const body = buf.subarray(off + 512, off + 512 + size);
105
197
  off += 512 + Math.ceil(size / 512) * 512;
106
198
 
107
- if (type === "x") { // pax extended header
108
- const m = body.toString("utf-8").match(/\d+ path=([^\n]*)\n/);
109
- if (m) longName = m[1];
199
+ if (type === "x") { // pax extended header, for the next entry
200
+ pax = parsePax(body);
201
+ continue;
202
+ }
203
+ if (type === "g") { // pax global header: validated, not used
204
+ parsePax(body);
110
205
  continue;
111
206
  }
112
207
  if (type === "L") { // GNU long name
113
208
  longName = cstr(body, 0, body.length);
114
209
  continue;
115
210
  }
116
- if (type === "g") continue;
117
- if (longName) { name = longName; longName = null; }
211
+ if (type === "K") continue; // GNU long link name
212
+ if (pax.path != null) name = pax.path;
213
+ else if (longName) name = longName;
214
+ pax = {};
215
+ longName = null;
118
216
  if (type !== "0" && type !== "\0" && type !== "7") continue;
119
217
  const parts = name.split("/").filter(Boolean);
120
218
  parts.shift(); // top-level folder, usually "package"
121
219
  if (parts.length === 0 || parts.includes("..")) continue;
122
- files.set(parts.join("/"), Buffer.from(body));
220
+ const rel = parts.join("/");
221
+ if (size > maxFile) {
222
+ files.set(rel, Buffer.alloc(0));
223
+ files.oversize.set(rel, size);
224
+ } else {
225
+ files.set(rel, Buffer.from(body));
226
+ files.oversize.delete(rel);
227
+ }
123
228
  }
229
+ if (off !== buf.length) throw new TarError(`trailing partial block at byte ${off}`);
124
230
  return files;
125
231
  }
126
232
 
233
+ /** gunzip with an output cap, then untar. Throws on any anomaly. */
234
+ export function unpackTgz(tgz) {
235
+ return untar(gunzipSync(tgz, { maxOutputLength: MAX_UNPACKED_BYTES }));
236
+ }
237
+
238
+ /** A verified tarball that could not be unpacked safely: no files, an anomaly. */
239
+ function unreadable(kind, e) {
240
+ return new MapSource(new Map(), kind, { anomaly: `tarball could not be read safely (${e.message})` });
241
+ }
242
+
127
243
  /** Split an SRI string into { algo, hex } entries. */
128
244
  export function parseIntegrity(sri) {
129
245
  return String(sri || "").split(/\s+/).filter(Boolean).map((s) => {
@@ -155,11 +271,16 @@ export function sourceFromNpmCache(integrity, cacheDir = npmCacheDir()) {
155
271
  for (const { algo, hex } of parseIntegrity(integrity)) {
156
272
  const p = join(cacheDir, "_cacache", "content-v2", algo, hex.slice(0, 2), hex.slice(2, 4), hex.slice(4));
157
273
  if (!existsSync(p)) continue;
274
+ let tgz;
275
+ try { tgz = readFileSync(p); } catch { continue; }
276
+ if (!verifyIntegrity(tgz, integrity)) continue; // corrupt entry, try the next hash
277
+ // The bytes are the ones the lockfile pins. If they do not unpack cleanly,
278
+ // say so: returning null would let the package pass as merely "not cached".
158
279
  try {
159
- const tgz = readFileSync(p);
160
- if (!verifyIntegrity(tgz, integrity)) continue;
161
- return new MapSource(untar(gunzipSync(tgz)), "npm-cache");
162
- } catch { /* corrupt entry, try the next hash */ }
280
+ return new MapSource(unpackTgz(tgz), "npm-cache");
281
+ } catch (e) {
282
+ return unreadable("npm-cache", e);
283
+ }
163
284
  }
164
285
  return null;
165
286
  }
@@ -167,5 +288,9 @@ export function sourceFromNpmCache(integrity, cacheDir = npmCacheDir()) {
167
288
  /** Unpack a verified tarball buffer (used by --online). */
168
289
  export function sourceFromTarball(tgz, integrity, kind = "registry") {
169
290
  if (integrity && !verifyIntegrity(tgz, integrity)) return null;
170
- return new MapSource(untar(gunzipSync(tgz)), kind);
291
+ try {
292
+ return new MapSource(unpackTgz(tgz), kind);
293
+ } catch (e) {
294
+ return unreadable(kind, e);
295
+ }
171
296
  }
@@ -1,12 +1,15 @@
1
1
  /**
2
- * Typosquat and slopsquat checks for npm package names (`penthera deps`).
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
5
  * names (lib/deps/popular-packages.json, 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
9
- * `electorn` (for electron) shipped malware.
9
+ * `electorn` (for electron) shipped malware on npm, and `python3-dateutil`
10
+ * and `jeIlyfish` on PyPI. PyPI names are compared after PEP 503
11
+ * normalisation, so `python_dateutil` (the same project as python-dateutil)
12
+ * is not a hit, while `pythondateutil` is.
10
13
  *
11
14
  * Slopsquatting is the same attack aimed at AI assistants: registering names
12
15
  * a model is likely to hallucinate. Offline, the useful signal is the same
@@ -14,15 +17,17 @@
14
17
  */
15
18
  import { readFileSync } from "node:fs";
16
19
 
17
- let _popular = null;
20
+ 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" };
18
23
 
19
- /** The vendored popular-name list: { source, names: string[] }. */
20
- export function loadPopular() {
21
- if (!_popular) {
22
- const data = JSON.parse(readFileSync(new URL("./popular-packages.json", import.meta.url), "utf-8"));
23
- _popular = { ...data, set: new Set(data.names), rank: new Map(data.names.map((n, i) => [n, i + 1])) };
24
+ /** The vendored popular-name list for an ecosystem: { source, names: string[] }. */
25
+ export function loadPopular(ecosystem = "npm") {
26
+ if (!_popular[ecosystem]) {
27
+ const data = JSON.parse(readFileSync(new URL(LISTS[ecosystem], import.meta.url), "utf-8"));
28
+ _popular[ecosystem] = { ...data, ecosystem, set: new Set(data.names), rank: new Map(data.names.map((n, i) => [n, i + 1])) };
24
29
  }
25
- return _popular;
30
+ return _popular[ecosystem];
26
31
  }
27
32
 
28
33
  /** Damerau-Levenshtein (optimal string alignment) distance, capped at max+1. */
@@ -51,6 +56,8 @@ export function editDistance(a, b, max = 2) {
51
56
  }
52
57
 
53
58
  const JS_SUFFIX = /(?:[.-]?js|-node)$/;
59
+ // PyPI: "python-requests", "requests-py", "py-requests" imitate "requests".
60
+ const PY_AFFIX = /^(?:python3?|py)-|-(?:python3?|py)$/;
54
61
 
55
62
  /** Separator- and scope-insensitive form: "@types/lodash.merge" -> "typeslodashmerge". */
56
63
  const squash = (s) => s.replace(/^@/, "").replace(/[-_./]/g, "");
@@ -80,9 +87,12 @@ export function checkName(name, popular = loadPopular()) {
80
87
  if (squash(name) === tSquash) {
81
88
  distance = 1;
82
89
  kind = "separator or scope swap";
83
- } else if (name.replace(JS_SUFFIX, "") === target) {
90
+ } else if (popular.ecosystem !== "pypi" && name.replace(JS_SUFFIX, "") === target) {
84
91
  distance = 1;
85
92
  kind = "popular name plus a js/node suffix";
93
+ } else if (popular.ecosystem === "pypi" && target.length >= 4 && name.replace(PY_AFFIX, "") === target) {
94
+ distance = 1;
95
+ kind = "popular name plus a python/py affix";
86
96
  } else if (max > 0 && !isScoped === !target.startsWith("@")) {
87
97
  distance = editDistance(name, target, max);
88
98
  if (distance > max) continue;
@@ -100,10 +110,11 @@ export function checkName(name, popular = loadPopular()) {
100
110
 
101
111
  /**
102
112
  * Check a list of package names. Returns hits sorted by name.
103
- * @param {string[]} names
113
+ * @param {string[]} names PyPI names must already be PEP 503 normalised
114
+ * @param {"npm"|"pypi"} ecosystem
104
115
  */
105
- export function findTyposquats(names) {
106
- const popular = loadPopular();
116
+ export function findTyposquats(names, ecosystem = "npm") {
117
+ const popular = loadPopular(ecosystem);
107
118
  const out = [];
108
119
  for (const name of [...new Set(names)].sort()) {
109
120
  const hit = checkName(name, popular);
package/lib/fix/apply.js CHANGED
@@ -2,12 +2,13 @@
2
2
  * applyDiff: the one place fix recipes' diffs get written to disk.
3
3
  *
4
4
  * Planning never writes. Applying is a separate, explicit step that refuses to
5
- * write outside the repo, into .git, or into a dirty git tree (unless the caller
6
- * passes allowDirty), and writes nothing unless every hunk applies.
5
+ * write outside the repo, into any .git (any case, any depth), to .gitmodules,
6
+ * through a symlink anywhere in the path, or into a dirty git tree (unless the
7
+ * caller passes allowDirty), and writes nothing unless every hunk applies.
7
8
  */
8
- import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
9
- import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
10
- import { execFileSync } from "node:child_process";
9
+ import { existsSync, readFileSync, mkdirSync, realpathSync, lstatSync, openSync, writeSync, closeSync, constants } from "node:fs";
10
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
+ import { createGit } from "../../src/agent/git.js";
11
12
  import { parsePatch, applyHunks } from "./diff.js";
12
13
 
13
14
  export class FixError extends Error {
@@ -23,19 +24,64 @@ function inside(root, p) {
23
24
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
24
25
  }
25
26
 
27
+ // Code points HFS+ ignores in names, so ".g\u200cit" is ".git" on macOS (CVE-2014-9390).
28
+ const IGNORABLE = /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u206a-\u206f\ufeff]/g;
29
+
30
+ /**
31
+ * The name a path segment resolves to on case-insensitive file systems
32
+ * (macOS, Windows): case folded, ignorable code points dropped, and the
33
+ * trailing dots and spaces Windows strips removed.
34
+ */
35
+ export function canonicalSegment(seg) {
36
+ return String(seg).normalize("NFC").replace(IGNORABLE, "").replace(/[. ]+$/, "").toLowerCase();
37
+ }
38
+
39
+ /** True for a segment that names the git directory: .git, .GIT, .Git., or the 8.3 alias GIT~1. */
40
+ export function isGitDirSegment(seg) {
41
+ const c = canonicalSegment(seg);
42
+ return c === ".git" || /^git~\d+$/.test(c);
43
+ }
44
+
45
+ /** Files a fix diff may never touch even though they live in the work tree. */
46
+ const PROTECTED_FILES = new Set([".gitmodules"]);
47
+
48
+ /**
49
+ * Throws unless no component of `target` below `root` is a symlink. Every
50
+ * existing component is lstat-ed, so neither a symlinked directory nor a
51
+ * dangling symlink at the file itself can redirect a write.
52
+ */
53
+ function assertNoSymlinks(root, target, file) {
54
+ let cur = root;
55
+ for (const seg of relative(root, target).split(sep)) {
56
+ cur = join(cur, seg);
57
+ let st;
58
+ try {
59
+ st = lstatSync(cur);
60
+ } catch (e) {
61
+ if (e.code === "ENOENT") return;
62
+ throw e;
63
+ }
64
+ if (st.isSymbolicLink()) {
65
+ throw new FixError(`Refusing to write ${file}: ${relative(root, cur)} is a symlink, and fix diffs never write through symlinks`, "SYMLINK");
66
+ }
67
+ }
68
+ }
69
+
26
70
  /** Resolve a diff path to an absolute path inside the repo, or throw. */
27
71
  function safeTarget(root, file) {
28
- if (!file || isAbsolute(file) || file.includes("\0")) {
72
+ if (!file || isAbsolute(file) || file.includes("\0") || /^[a-zA-Z]:/.test(file)) {
29
73
  throw new FixError(`Refusing to write ${JSON.stringify(file)}: paths in a diff must be relative to the repo`, "OUTSIDE_REPO");
30
74
  }
75
+ const segs = file.split(/[\\/]/);
76
+ if (segs.includes("..")) throw new FixError(`Refusing to write ${file}: it is outside the repo`, "OUTSIDE_REPO");
77
+ // Any .git at any depth, in any case: on macOS .GIT/hooks/pre-commit is .git/hooks/pre-commit.
78
+ if (segs.some(isGitDirSegment)) throw new FixError(`Refusing to write ${file}: it is inside .git`, "OUTSIDE_REPO");
79
+ if (segs.some((s) => PROTECTED_FILES.has(canonicalSegment(s)))) {
80
+ throw new FixError(`Refusing to write ${file}: fix diffs never edit .gitmodules (it can point git at other repositories)`, "PROTECTED");
81
+ }
31
82
  const target = resolve(root, file);
32
- if (!inside(root, target)) throw new FixError(`Refusing to write ${file}: it is outside the repo`, "OUTSIDE_REPO");
33
- if (relative(root, target).split(sep)[0] === ".git") throw new FixError(`Refusing to write ${file}: it is inside .git`, "OUTSIDE_REPO");
34
- // Follow symlinks: the real location of the file (or its nearest existing
35
- // parent, for a new file) must still be inside the repo.
36
- let probe = target;
37
- while (!existsSync(probe)) probe = dirname(probe);
38
- if (!inside(root, realpathSync(probe))) throw new FixError(`Refusing to write ${file}: it resolves outside the repo`, "OUTSIDE_REPO");
83
+ if (!inside(root, target) || target === root) throw new FixError(`Refusing to write ${file}: it is outside the repo`, "OUTSIDE_REPO");
84
+ assertNoSymlinks(root, target, file);
39
85
  return target;
40
86
  }
41
87
 
@@ -43,8 +89,10 @@ function safeTarget(root, file) {
43
89
  export function assertCleanTree(repoPath) {
44
90
  let status;
45
91
  try {
46
- execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: repoPath, stdio: ["ignore", "pipe", "ignore"] });
47
- status = execFileSync("git", ["status", "--porcelain", "--", "."], { cwd: repoPath, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
92
+ // The hardened runner: no fsmonitor, no hooks, no transports (src/agent/git.js).
93
+ const git = createGit(repoPath);
94
+ git.run("rev-parse", "--is-inside-work-tree");
95
+ status = git.run("status", "--porcelain", "--", ".");
48
96
  } catch {
49
97
  throw new FixError(`${repoPath} is not a git work tree, so a fix could not be reviewed or reverted. Commit it to git first, or pass --allow-dirty.`, "NOT_GIT");
50
98
  }
@@ -53,6 +101,24 @@ export function assertCleanTree(repoPath) {
53
101
  }
54
102
  }
55
103
 
104
+ const NOFOLLOW = constants.O_NOFOLLOW || 0;
105
+
106
+ /** Write one file: create exclusively, or overwrite in place, never through a symlink. */
107
+ function writeTarget(root, w) {
108
+ assertNoSymlinks(root, w.target, w.path); // again, right before writing
109
+ mkdirSync(dirname(w.target), { recursive: true });
110
+ assertNoSymlinks(root, w.target, w.path);
111
+ const flags = constants.O_WRONLY | NOFOLLOW | (w.created ? constants.O_CREAT | constants.O_EXCL : constants.O_TRUNC);
112
+ const fd = openSync(w.target, flags, 0o644);
113
+ try {
114
+ const buf = Buffer.from(w.after, "utf-8");
115
+ let off = 0;
116
+ while (off < buf.length) off += writeSync(fd, buf, off, buf.length - off);
117
+ } finally {
118
+ closeSync(fd);
119
+ }
120
+ }
121
+
56
122
  /**
57
123
  * Apply a unified diff to files under repoPath.
58
124
  *
@@ -73,6 +139,7 @@ export function applyDiff(repoPath, diff, opts = {}) {
73
139
  if (f.oldPath && f.oldPath !== f.newPath) throw new FixError(`Refusing to rename ${f.oldPath}: fix diffs never rename files`, "RENAME");
74
140
  const target = safeTarget(root, f.newPath);
75
141
  const exists = existsSync(target);
142
+ if (exists && !lstatSync(target).isFile()) throw new FixError(`Refusing to write ${f.newPath}: it is not a regular file`, "CONFLICT");
76
143
  if (!f.oldPath && exists) throw new FixError(`${f.newPath} already exists; the plan expected to create it`, "CONFLICT");
77
144
  if (f.oldPath && !exists) throw new FixError(`${f.newPath} does not exist; the plan expected to edit it`, "CONFLICT");
78
145
  const before = exists ? readFileSync(target, "utf-8") : null;
@@ -86,10 +153,7 @@ export function applyDiff(repoPath, diff, opts = {}) {
86
153
  }
87
154
 
88
155
  if (!opts.dryRun) {
89
- for (const w of writes) {
90
- mkdirSync(dirname(w.target), { recursive: true });
91
- writeFileSync(w.target, w.after);
92
- }
156
+ for (const w of writes) writeTarget(root, w);
93
157
  }
94
158
  return { applied: !opts.dryRun, files: writes.map((w) => ({ path: w.path, created: w.created })) };
95
159
  }