penthera 1.4.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 (114) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +148 -575
  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/compliance-cra.md +141 -0
  20. package/docs/compliance-nis2.md +3 -1
  21. package/docs/dashboard.md +244 -0
  22. package/docs/evidence.md +8 -4
  23. package/docs/examples/github-actions-deps.yml +4 -4
  24. package/docs/examples/github-actions-scan.yml +4 -4
  25. package/docs/index.md +11 -2
  26. package/docs/internal-audit.md +83 -0
  27. package/docs/marketing.md +151 -0
  28. package/docs/plan.md +101 -0
  29. package/docs/privacy.md +38 -0
  30. package/docs/references.bib +10 -0
  31. package/docs/references.md +1 -0
  32. package/docs/research/socket-positioning-notes.md +183 -0
  33. package/docs/use-cases/README.md +14 -0
  34. package/docs/use-cases/agencies.md +71 -0
  35. package/docs/use-cases/ai-coding-teams.md +81 -0
  36. package/docs/use-cases/eu-sme-compliance.md +68 -0
  37. package/docs/use-cases/open-source-maintainers.md +75 -0
  38. package/docs/use-cases/solo-founder.md +65 -0
  39. package/lib/blackbox/email-dns.js +5 -0
  40. package/lib/compliance/cra.js +525 -0
  41. package/lib/compliance/questionnaire.js +4 -2
  42. package/lib/deps/capabilities.js +49 -12
  43. package/lib/deps/index.js +314 -30
  44. package/lib/deps/license.js +329 -0
  45. package/lib/deps/lockfile.js +29 -3
  46. package/lib/deps/popular-pypi.json +1 -0
  47. package/lib/deps/python/capabilities.js +447 -0
  48. package/lib/deps/python/index.js +234 -0
  49. package/lib/deps/python/lockfiles.js +359 -0
  50. package/lib/deps/python/source.js +396 -0
  51. package/lib/deps/python/toml.js +219 -0
  52. package/lib/deps/report.js +54 -6
  53. package/lib/deps/source.js +149 -24
  54. package/lib/deps/spdx.json +1 -0
  55. package/lib/deps/typosquat.js +24 -13
  56. package/lib/fix/apply.js +83 -19
  57. package/lib/hook/index.js +345 -0
  58. package/lib/hook/policy.js +62 -0
  59. package/lib/install/index.js +59 -18
  60. package/lib/install/policy.js +27 -7
  61. package/lib/jurisdictions/nl.json +9 -0
  62. package/lib/memory/repo-suppress.js +11 -3
  63. package/lib/references.json +77 -0
  64. package/lib/remediation/index.js +9 -0
  65. package/lib/retirejs.js +69 -17
  66. package/lib/rules/regex-guard.js +368 -0
  67. package/lib/templates.js +13 -6
  68. package/lib/tls.js +7 -0
  69. package/lib/whitebox/aibom.js +384 -0
  70. package/lib/whitebox/frameworks.js +8 -5
  71. package/lib/whitebox/sbom.js +45 -10
  72. package/lib/whitebox/secrets.js +31 -8
  73. package/package.json +8 -3
  74. package/skills/penthera/SKILL.md +55 -8
  75. package/src/cli/deps.js +81 -6
  76. package/src/cli/evidence.js +14 -4
  77. package/src/cli/hook.js +131 -0
  78. package/src/cli/install.js +14 -5
  79. package/src/cli/report-outputs.js +25 -2
  80. package/src/cli/ui.js +89 -0
  81. package/src/cli.js +81 -11
  82. package/src/report/audience.js +10 -10
  83. package/src/report/evidence.js +33 -14
  84. package/src/report/footer.js +11 -0
  85. package/src/report/markdown.js +6 -3
  86. package/src/report/md-escape.js +16 -0
  87. package/src/reporter.js +1 -0
  88. package/src/scan-url.js +5 -1
  89. package/src/ui/api/data.js +309 -0
  90. package/src/ui/api/demo.js +464 -0
  91. package/src/ui/api/routes.js +134 -0
  92. package/src/ui/api/runs.js +243 -0
  93. package/src/ui/api/summarize.js +451 -0
  94. package/src/ui/security.js +153 -0
  95. package/src/ui/server.js +186 -0
  96. package/src/ui/static/app.css +723 -0
  97. package/src/ui/static/favicon.svg +1 -0
  98. package/src/ui/static/index.html +57 -0
  99. package/src/ui/static/js/api.js +29 -0
  100. package/src/ui/static/js/app.js +138 -0
  101. package/src/ui/static/js/charts.js +207 -0
  102. package/src/ui/static/js/copy.js +79 -0
  103. package/src/ui/static/js/dom.js +120 -0
  104. package/src/ui/static/js/format.js +42 -0
  105. package/src/ui/static/js/theme.js +10 -0
  106. package/src/ui/static/js/typer.js +99 -0
  107. package/src/ui/static/js/views/ai.js +68 -0
  108. package/src/ui/static/js/views/deps.js +96 -0
  109. package/src/ui/static/js/views/evidence.js +115 -0
  110. package/src/ui/static/js/views/findings.js +165 -0
  111. package/src/ui/static/js/views/history.js +92 -0
  112. package/src/ui/static/js/views/overview.js +184 -0
  113. package/src/ui/static/js/views/run.js +349 -0
  114. package/src/ui/static/js/views/settings.js +67 -0
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Static capability detection for Python packages (`penthera deps`).
3
+ *
4
+ * The Python half of lib/deps/capabilities.js, with the same capability ids,
5
+ * evidence shape and return value, so the diff, the PR comment, policy and the
6
+ * install firewall treat a PyPI package like an npm one. Comments and
7
+ * docstrings are blanked by a small tokenizer, then line-level patterns run
8
+ * over the code. No AST, no interpreter, no network.
9
+ *
10
+ * Python-specific install-time and persistence paths:
11
+ * - setup.py with custom commands (cmdclass) runs when pip builds an sdist;
12
+ * - *.pth files in site-packages run every line that starts with "import"
13
+ * on each interpreter start, which malware uses for persistence.
14
+ *
15
+ * Literals that name attack indicators are assembled at runtime (see
16
+ * lib/deps/capabilities.js) so this file does not read like a payload.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import {
20
+ CAPABILITY_ORDER, INDICATORS, RISKY_FOR_INSTALL, add, snippet, obfuscationSignals, mergeCaps, findLine,
21
+ } from "../capabilities.js";
22
+
23
+ const j = (...parts) => parts.join("");
24
+ const MAX_PY_BYTES = 8 * 1024 * 1024;
25
+ const EXFIL_WINDOW = 40;
26
+ const NEAR_WINDOW = 30;
27
+ const PTH_WINDOW = 5;
28
+
29
+ // ── Tokenizer: blank comments and docstrings, keep strings and line numbers ──
30
+
31
+ const STRING_START = /[rRbBuUfF]{0,2}(?:"""|'''|"|')/y;
32
+
33
+ /**
34
+ * Replace comments and bare string statements (docstrings) with spaces,
35
+ * keeping newlines so line numbers stay true. Other strings stay intact: they
36
+ * hold the paths, URLs and command names the patterns look for. A module that
37
+ * decodes or runs its own __doc__ keeps its docstrings, since hiding a payload there and
38
+ * exec'ing __doc__ is a known trick.
39
+ */
40
+ export function blankPython(src) {
41
+ const keepDocstrings = /\b(?:exec|eval|compile|\w*decode\w*|decompress|loads)\s*\([^)\n]*\b__doc__\b/.test(src);
42
+ const n = src.length;
43
+ const parts = [];
44
+ let last = 0;
45
+ let i = 0;
46
+ let depth = 0;
47
+ let stmtStart = true;
48
+ const blank = (s) => s.replace(/[^\n]/g, " ");
49
+ while (i < n) {
50
+ const ch = src[i];
51
+ if (ch === "#") {
52
+ let end = src.indexOf("\n", i);
53
+ if (end === -1) end = n;
54
+ parts.push(src.slice(last, i), " ".repeat(end - i));
55
+ i = last = end;
56
+ continue;
57
+ }
58
+ if (ch === "\n") {
59
+ if (depth === 0) stmtStart = true;
60
+ i++;
61
+ continue;
62
+ }
63
+ if (ch === " " || ch === "\t" || ch === "\r" || ch === "\f") { i++; continue; }
64
+ if (ch === "\\" && (src[i + 1] === "\n" || (src[i + 1] === "\r" && src[i + 2] === "\n"))) {
65
+ i += src[i + 1] === "\n" ? 2 : 3;
66
+ continue;
67
+ }
68
+ STRING_START.lastIndex = i;
69
+ const sm = (i === 0 || !/[\w]/.test(src[i - 1])) && STRING_START.exec(src);
70
+ if (sm) {
71
+ const quote = sm[0].replace(/^[rRbBuUfF]+/, "");
72
+ let k = i + sm[0].length;
73
+ if (quote.length === 3) {
74
+ while (k < n && !src.startsWith(quote, k)) k += src[k] === "\\" ? 2 : 1;
75
+ k = Math.min(n, k + 3);
76
+ } else {
77
+ while (k < n && src[k] !== quote && src[k] !== "\n") k += src[k] === "\\" ? 2 : 1;
78
+ if (src[k] === quote) k++;
79
+ }
80
+ if (stmtStart && depth === 0 && !keepDocstrings) {
81
+ let after = k;
82
+ while (after < n && (src[after] === " " || src[after] === "\t")) after++;
83
+ if (after >= n || src[after] === "\n" || src[after] === "\r" || src[after] === "#" || src[after] === ";") {
84
+ parts.push(src.slice(last, i), blank(src.slice(i, k)));
85
+ last = k;
86
+ }
87
+ }
88
+ stmtStart = false;
89
+ i = k;
90
+ continue;
91
+ }
92
+ if (ch === "(" || ch === "[" || ch === "{") depth++;
93
+ else if ((ch === ")" || ch === "]" || ch === "}") && depth > 0) depth--;
94
+ if (ch === ";" && depth === 0) stmtStart = true;
95
+ else if (ch === ":" && depth === 0) stmtStart = true; // `def f(): "doc"` on one line
96
+ else stmtStart = false;
97
+ i++;
98
+ }
99
+ parts.push(src.slice(last));
100
+ return parts.join("");
101
+ }
102
+
103
+ // ── Patterns ──────────────────────────────────────────────────────────────
104
+
105
+ const esc = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
106
+ /** `import a, b.c as d` / `from a.b import c` for any of the given modules. */
107
+ const importRe = (mods) => {
108
+ const m = mods.map(esc).join("|");
109
+ return [
110
+ new RegExp(String.raw`(?:^|[;:]\s*)\s*from\s+(?:${m})(?:\.[\w.]+)?\s+import\b`),
111
+ new RegExp(String.raw`(?:^|[;:]\s*)\s*import\s+(?:[\w.]+(?:\s+as\s+\w+)?\s*,\s*)*(?:${m})(?![\w])`),
112
+ ];
113
+ };
114
+
115
+ const NET_MODS = ["socket", "urllib.request", "urllib2", "urllib3", "http.client", "httplib", "requests", "httpx", "aiohttp", "ftplib", "smtplib", "telnetlib", "websocket", "websockets", "pycurl", "paramiko"];
116
+ const DECODE_CALL = String.raw`(?:base64\.\w*decode\w*|b64decode|b32decode|b85decode|a85decode|zlib\.decompress|decompress|marshal\.loads|bz2\.decompress|lzma\.decompress|gzip\.decompress|codecs\.decode|bytes\.fromhex|binascii\.\w+|unhexlify)`;
117
+ const HOME_VARS = "HOME|USERPROFILE|APPDATA|LOCALAPPDATA|XDG_CONFIG_HOME";
118
+
119
+ const P = {
120
+ shell: [
121
+ ...importRe(["subprocess", "pty", "commands"]),
122
+ /\bos\.(?:system|popen|exec[lv]p?e?|spawn[lv]p?e?|posix_spawnp?)\s*\(/,
123
+ /\bpty\.spawn\s*\(/,
124
+ /\basyncio\.create_subprocess_(?:exec|shell)\s*\(/,
125
+ /\b__import__\s*\(\s*['"](?:os|subprocess|pty)['"]\s*\)\s*\.\s*(?:system|popen|run|Popen|call|check_output|getoutput|spawn)\b/,
126
+ ],
127
+ network: [
128
+ ...importRe(NET_MODS),
129
+ /(?:^|[;:]\s*)\s*from\s+urllib\s+import\s+[\w\s,()]*\b(?:request|urlopen)\b/,
130
+ /\burlopen\s*\(/,
131
+ /\b__import__\s*\(\s*['"](?:socket|urllib\.request|requests|http\.client)['"]/,
132
+ ],
133
+ env: [/\bos\.environ\b/, /\bos\.getenv\s*\(/, /\bos\.environb\b/],
134
+ envSerialise: /\b(?:json\.dumps?|str|repr|pickle\.dumps?|base64\.\w*encode|yaml\.\w*dump|marshal\.dumps?|urlencode)\s*\(\s*(?:dict\s*\(\s*)?(?:\*\*\s*)?os\.environ\b(?!\s*[.[])|\b(?:json\.dumps?|str|repr)\s*\(\s*\{\s*\*\*\s*os\.environ\s*\}/,
135
+ envBulk: [
136
+ /\bos\.environ\.(?:items|keys|values)\s*\(/,
137
+ /\bfor\s+[\w\s,()]+\s+in\s+os\.environ\s*:/,
138
+ ],
139
+ eval: [
140
+ /(?<![\w.]|\bdef\s{1,4})eval\s*\(/,
141
+ /(?<![\w.]|\bdef\s{1,4})exec\s*\(/,
142
+ /(?<![\w.]|\bdef\s{1,4})execfile\s*\(/,
143
+ /(?<![\w.]|\bdef\s{1,4})compile\s*\(/,
144
+ /\btypes\.(?:FunctionType|CodeType)\s*\(/,
145
+ /\brunpy\.run_(?:path|module)\s*\(/,
146
+ ],
147
+ dynamicRequire: [
148
+ /(?<![\w.])__import__\s*\(\s*(?!['"][\w.]+['"]\s*[,)])(?!\))/,
149
+ /\bimport_module\s*\(\s*(?!['"][\w.]+['"]\s*[,)])(?!\))/,
150
+ ],
151
+ deserialize: [
152
+ /\b(?:pickle|cPickle|_pickle|dill|cloudpickle)\.(?:loads?|Unpickler)\s*\(/,
153
+ /\bmarshal\.loads?\s*\(/,
154
+ /\byaml\.unsafe_load(?:_all)?\s*\(/,
155
+ /\byaml\.load(?:_all)?\s*\((?![^)]*Loader\s*=\s*(?:yaml\.)?C?(?:Safe|Base)Loader)/,
156
+ ],
157
+ native: [/\bctypes\.(?:CDLL|cdll|WinDLL|windll|PyDLL|LibraryLoader)\b/, /\bffi\.dlopen\s*\(/],
158
+ fsReadCall: /(?<![\w.])open\s*\(|\.read_(?:text|bytes)\s*\(/,
159
+ fsWriteCall: /(?<![\w])(?:io\.|codecs\.)?open\s*\([^#]*?(?:,\s*|mode\s*=\s*)[rbuf]?['"][rbt]*[wax][+bt]*['"]|\.(?:write_text|write_bytes)\s*\(|\bshutil\.(?:copy|copy2|copyfile|copytree|move)\s*\(|\bos\.(?:rename|replace|symlink|link|chmod)\s*\(/,
160
+ outsidePath: new RegExp([
161
+ String.raw`\bos\.path\.expanduser\s*\(`,
162
+ String.raw`\bPath\.home\s*\(\s*\)`,
163
+ String.raw`\bos\.(?:environ(?:\.get\s*\(|\s*\[)|getenv\s*\()\s*['"](?:${HOME_VARS})['"]`,
164
+ String.raw`['"]~[/\\]`,
165
+ String.raw`['"]/(?:etc|usr|var|Library|System)/`,
166
+ String.raw`['"][A-Z]:\\\\(?:Windows|Users)`,
167
+ String.raw`\bsite\.(?:getsitepackages|getusersitepackages|USER_SITE)\b`,
168
+ String.raw`\bsysconfig\.get_paths?\s*\(`,
169
+ ].join("|")),
170
+ pthPath: /['"][^'"\n]*\.pth['"]/,
171
+ // The classic dropper in one expression: exec(base64.b64decode(...)), exec(zlib.decompress(...)).
172
+ execDecode: new RegExp(String.raw`(?<![\w.])(?:exec|eval)\s*\(\s*(?:[\w.]+\s*\(\s*)*${DECODE_CALL}\s*\(`),
173
+ pyarmor: /\b__pyarmor__\b|\bpyarmor_runtime\b/,
174
+ };
175
+ const DECODE_RE = new RegExp(String.raw`\b${DECODE_CALL}\s*\(`);
176
+
177
+ const AI_CLI_CALL_RE = new RegExp(
178
+ String.raw`\b(?:run|Popen|call|check_call|check_output|getoutput|getstatusoutput|system|popen|spawn|create_subprocess_exec|create_subprocess_shell)\s*\(\s*\[?\s*[rbuf]?['"](?:${INDICATORS.AI_CLIS.join("|")})(?:['"]|\s)`,
179
+ );
180
+ const GH_TOKEN_LIST_RE = new RegExp(j("['\"]gh['\"]\\s*,\\s*['\"]auth['\"]\\s*,\\s*['\"]", "token['\"]"));
181
+
182
+ // ── Per-file scan ─────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Scan one Python file's source. Same return shape as scanSource() for JS:
186
+ * { caps, exfil, aiConfigProject }, plus pthWrite (a write of a .pth file).
187
+ */
188
+ export function scanPythonSource(src, file) {
189
+ const caps = {};
190
+ const code = blankPython(src);
191
+ const lines = code.split("\n");
192
+ const has = { shell: false, network: false, fsRead: false, outside: false, envBulk: false, eval: false };
193
+ const firstHit = {};
194
+ const netLines = [];
195
+ const writeHits = [];
196
+ const outsideLines = [];
197
+ const pthLines = [];
198
+ const aiConfigHits = [];
199
+ const serialiseHits = [];
200
+
201
+ for (let i = 0; i < lines.length; i++) {
202
+ const l = lines[i];
203
+ if (!l.trim()) continue;
204
+ const ev = (re) => {
205
+ const m = l.match(re);
206
+ return m ? { file, line: i + 1, snippet: snippet(l, m.index) } : null;
207
+ };
208
+ const hit = (id, res) => {
209
+ for (const re of res) {
210
+ const e = ev(re);
211
+ if (e) { add(caps, id, e); return e; }
212
+ }
213
+ return null;
214
+ };
215
+ if (hit("shell", P.shell)) has.shell = true;
216
+ if (hit("network", P.network)) { has.network = true; netLines.push(i + 1); }
217
+ hit("env", P.env);
218
+ const ser = ev(P.envSerialise);
219
+ if (ser) { add(caps, "env-bulk", ser); has.envBulk = true; serialiseHits.push(ser); }
220
+ else if (hit("env-bulk", P.envBulk)) has.envBulk = true;
221
+ if (hit("eval", P.eval)) has.eval = true;
222
+ hit("dynamic-require", P.dynamicRequire);
223
+ hit("deserialize", P.deserialize);
224
+ hit("native", P.native);
225
+
226
+ const w = ev(P.fsWriteCall);
227
+ if (w) writeHits.push(w);
228
+ if (P.outsidePath.test(l)) { has.outside = true; outsideLines.push(i + 1); }
229
+ if (P.pthPath.test(l)) pthLines.push(i + 1);
230
+ if (P.fsReadCall.test(l)) has.fsRead = true;
231
+
232
+ const aiCfg = ev(INDICATORS.AI_CONFIG_RE);
233
+ if (aiCfg) aiConfigHits.push(aiCfg);
234
+ const aiCall = ev(AI_CLI_CALL_RE) || ev(INDICATORS.AI_CLI_FLAG_RE);
235
+ if (aiCall) firstHit.aiCli = firstHit.aiCli || aiCall;
236
+ const ghCmd = ev(INDICATORS.GH_TOKEN_CMD_RE) || ev(GH_TOKEN_LIST_RE);
237
+ if (ghCmd) firstHit.ghCmd = firstHit.ghCmd || ghCmd;
238
+ const ghFile = ev(INDICATORS.GH_CRED_FILE_RE);
239
+ if (ghFile) firstHit.ghFile = firstHit.ghFile || ghFile;
240
+ const ghShape = ev(INDICATORS.GH_TOKEN_REGEX_RE);
241
+ if (ghShape) firstHit.ghShape = firstHit.ghShape || ghShape;
242
+ const dropper = ev(P.execDecode);
243
+ if (dropper) firstHit.dropper = firstHit.dropper || dropper;
244
+ const armor = ev(P.pyarmor);
245
+ if (armor) firstHit.armor = firstHit.armor || armor;
246
+ }
247
+
248
+ const near = (line, list) => list.some((n) => Math.abs(n - line) <= NEAR_WINDOW);
249
+ const outsideWrite = writeHits.find((w) => near(w.line, outsideLines));
250
+ if (outsideWrite) add(caps, "fs-write", outsideWrite);
251
+ // Writing a .pth file installs code that runs on every interpreter start.
252
+ const pthWrite = writeHits.find((w) => pthLines.some((n) => Math.abs(n - w.line) <= PTH_WINDOW)) || null;
253
+ if (pthWrite) add(caps, "startup-hook", { ...pthWrite, snippet: `writes a .pth file: ${pthWrite.snippet}` });
254
+
255
+ const writeLines = writeHits.map((w) => w.line);
256
+ const aiWrites = aiConfigHits.filter((h) => near(h.line, writeLines));
257
+ const homeWrite = aiWrites.find((h) => near(h.line, outsideLines) || INDICATORS.DESKTOP_CONFIG_RE.test(h.snippet));
258
+ if (homeWrite) add(caps, "ai-config-write", homeWrite);
259
+ const aiConfigProject = homeWrite ? null : aiWrites[0] || null;
260
+ if (firstHit.aiCli && has.shell) add(caps, "ai-cli-invoke", firstHit.aiCli);
261
+ if (firstHit.ghCmd && has.shell) add(caps, "token-harvest", firstHit.ghCmd);
262
+ else if (firstHit.ghFile && (has.fsRead || has.network)) add(caps, "token-harvest", firstHit.ghFile);
263
+ else if (firstHit.ghShape && has.network && (has.envBulk || has.outside)) add(caps, "token-harvest", firstHit.ghShape);
264
+
265
+ if (firstHit.dropper) {
266
+ add(caps, "obfuscated", { ...firstHit.dropper, snippet: `decoded payload handed to exec/eval: ${firstHit.dropper.snippet}` });
267
+ } else if (firstHit.armor) {
268
+ add(caps, "obfuscated", { ...firstHit.armor, snippet: `PyArmor-protected code: ${firstHit.armor.snippet}` });
269
+ } else {
270
+ // Byte literals full of \x escapes are normal in Python (palettes, fonts,
271
+ // magic numbers); they only count next to dynamic code.
272
+ const obf = obfuscationSignals(code, { hasDynamicCode: has.eval, evalRes: P.eval, decodeRe: DECODE_RE, strongEscapes: has.eval });
273
+ if (obf.flagged) add(caps, "obfuscated", { file, line: obf.line, snippet: obf.signals.join("; ") });
274
+ }
275
+
276
+ const exfil = serialiseHits.find((e) => netLines.some((n) => Math.abs(n - e.line) <= EXFIL_WINDOW)) || null;
277
+ return { caps, exfil, aiConfigProject, pthWrite };
278
+ }
279
+
280
+ // ── .pth files ────────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Executable lines of a .pth file: site.py exec()s every line that starts
284
+ * with "import" (followed by a space or tab). Other lines are path entries.
285
+ */
286
+ export function pthImportLines(text) {
287
+ return String(text).split(/\r?\n/).map((l, i) => ({ line: i + 1, text: l }))
288
+ .filter((l) => /^import[ \t]/.test(l.text));
289
+ }
290
+
291
+ /** Top-level module names a .pth import line loads. */
292
+ function pthModules(lineText) {
293
+ const out = [];
294
+ for (const stmt of lineText.split(";")) {
295
+ const m = stmt.trim().match(/^import\s+(.+)$/);
296
+ if (m) for (const part of m[1].split(",")) out.push(part.trim().split(/\s+/)[0].split(".")[0]);
297
+ }
298
+ return out.filter((x) => /^[\w]+$/.test(x));
299
+ }
300
+
301
+ // ── setup.py ──────────────────────────────────────────────────────────────
302
+
303
+ const CMDCLASS_RE = /\bcmdclass\s*=|\bcmdclass\s*\[/;
304
+ const CUSTOM_CMD_RE = /\bclass\s+(\w+)\s*\(\s*(?:[\w.]*\b)?(install|develop|egg_info|build_py|build_ext|build|sdist|bdist_wheel|Command)\s*\)/;
305
+
306
+ // ── RECORD integrity ──────────────────────────────────────────────────────
307
+
308
+ /** RECORD hash of bytes: "sha256=<urlsafe base64, no padding>". */
309
+ export function recordHash(bytes, algo = "sha256") {
310
+ return `${algo}=${createHash(algo).update(bytes).digest("base64url")}`;
311
+ }
312
+
313
+ // ── Per-package scan ──────────────────────────────────────────────────────
314
+
315
+ const PY_EXT = /\.pyw?$/i;
316
+ const NATIVE_EXT = /\.(?:so(?:\.\d+)*|pyd|dylib|dll)$/i;
317
+
318
+ /**
319
+ * Scan a Python package through a source (lib/deps/source.js interface; a
320
+ * source may also expose recordHashes: Map<rel, "sha256=...">).
321
+ * @param {{ files(): string[], read(rel): Buffer, size(rel): number, recordHashes?: Map }} source
322
+ * @param {{ prefix?: string }} opts
323
+ * @returns same shape as scanPackage() for npm, plus startupHooks and modified
324
+ */
325
+ export function scanPythonPackage(source, opts = {}) {
326
+ const prefix = opts.prefix || "";
327
+ const caps = {};
328
+ const files = source.files();
329
+ const fileSet = new Set(files);
330
+ const fileCaps = {};
331
+ const exfil = [];
332
+ const skipped = [];
333
+ const modified = [];
334
+ const aiConfigProject = {};
335
+ let filesScanned = 0;
336
+
337
+ const checkRecord = (f, bytes) => {
338
+ const expected = source.recordHashes?.get(f);
339
+ if (!expected) return;
340
+ const [algo] = expected.split("=");
341
+ if (!/^sha(256|384|512)$/.test(algo)) return;
342
+ if (recordHash(bytes, algo) !== expected) modified.push(`${prefix}${f}`);
343
+ };
344
+
345
+ for (const f of files) {
346
+ if (NATIVE_EXT.test(f)) add(caps, "native", { file: `${prefix}${f}`, line: 1, snippet: "prebuilt native extension" });
347
+ }
348
+
349
+ for (const f of files) {
350
+ if (!PY_EXT.test(f)) continue;
351
+ if (source.size(f) > MAX_PY_BYTES) { skipped.push(f); continue; }
352
+ let bytes;
353
+ try { bytes = source.read(f); } catch { continue; }
354
+ checkRecord(f, bytes);
355
+ filesScanned++;
356
+ const r = scanPythonSource(bytes.toString("utf-8"), `${prefix}${f}`);
357
+ fileCaps[f] = Object.keys(r.caps);
358
+ if (r.aiConfigProject) aiConfigProject[f] = r.aiConfigProject;
359
+ mergeCaps(caps, r.caps);
360
+ if (r.exfil) exfil.push(r.exfil);
361
+ }
362
+
363
+ // Module name -> file, for resolving what a .pth or setup.py hook runs.
364
+ const moduleFile = (mod) => [`${mod}.py`, `${mod}/__init__.py`].find((f) => fileSet.has(f)) || null;
365
+ const riskyOf = (f) => (f && fileCaps[f] ? fileCaps[f].filter((c) => RISKY_FOR_INSTALL.includes(c) || c === "shell") : []);
366
+
367
+ // .pth files at the top level of site-packages (or of the package source).
368
+ const startupHooks = [];
369
+ for (const f of files.filter((x) => /^[^/]+\.pth$/i.test(x))) {
370
+ let text;
371
+ try {
372
+ const bytes = source.read(f);
373
+ checkRecord(f, bytes);
374
+ text = bytes.toString("utf-8");
375
+ } catch { continue; }
376
+ for (const l of pthImportLines(text)) {
377
+ const ev = { file: `${prefix}${f}`, line: l.line, snippet: snippet(l.text, 0) };
378
+ add(caps, "startup-hook", ev);
379
+ const risk = [];
380
+ const inline = scanPythonSource(l.text, `${prefix}${f}`);
381
+ const inlineBad = Object.keys(inline.caps).filter((c) => RISKY_FOR_INSTALL.includes(c) || c === "shell");
382
+ if (inlineBad.length) risk.push(`the line itself uses ${inlineBad.join(", ")}`);
383
+ mergeCaps(caps, Object.fromEntries(Object.entries(inline.caps).filter(([id]) => id !== "env")));
384
+ for (const mod of pthModules(l.text)) {
385
+ const mf = moduleFile(mod);
386
+ const bad = riskyOf(mf);
387
+ if (bad.length) risk.push(`imports ${mf} (${bad.join(", ")})`);
388
+ }
389
+ startupHooks.push({ ...ev, risk });
390
+ }
391
+ }
392
+
393
+ // Install-time code: setup.py custom commands, or an in-tree build backend.
394
+ const installScripts = {};
395
+ const installRisk = [];
396
+ if (fileSet.has("setup.py")) {
397
+ let text = "";
398
+ try { text = source.read("setup.py").toString("utf-8"); } catch { /* unreadable */ }
399
+ const code = blankPython(text);
400
+ const custom = code.match(CUSTOM_CMD_RE);
401
+ const bad = riskyOf("setup.py");
402
+ if (CMDCLASS_RE.test(code) || custom || bad.length) {
403
+ const needle = custom ? custom[0] : (code.match(CMDCLASS_RE)?.[0] || "setup(");
404
+ const line = findLine(code, needle);
405
+ const what = custom ? `custom ${custom[2]} command (${custom[1]})` : CMDCLASS_RE.test(code) ? "custom commands (cmdclass)" : "code at build time";
406
+ installScripts["setup.py"] = what;
407
+ add(caps, "install-scripts", { file: `${prefix}setup.py`, line, snippet: `setup.py: ${what}` });
408
+ if (bad.length) installRisk.push(`setup.py uses ${bad.join(", ")}`);
409
+ if (aiConfigProject["setup.py"]) {
410
+ add(caps, "ai-config-write", aiConfigProject["setup.py"]);
411
+ installRisk.push("setup.py writes AI agent config");
412
+ }
413
+ // Helper modules setup.py imports from the same tree.
414
+ for (const m of code.matchAll(/(?:^|\n)\s*(?:from\s+([\w]+)[\w.]*\s+import|import\s+([\w]+))/g)) {
415
+ const mf = moduleFile(m[1] || m[2]);
416
+ if (!mf || mf === "setup.py") continue;
417
+ const b = riskyOf(mf);
418
+ if (b.length) installRisk.push(`setup.py imports ${mf} (${b.join(", ")})`);
419
+ }
420
+ }
421
+ }
422
+ if (fileSet.has("pyproject.toml")) {
423
+ let text = "";
424
+ try { text = source.read("pyproject.toml").toString("utf-8"); } catch { /* unreadable */ }
425
+ if (/^\s*backend-path\s*=/m.test(text)) {
426
+ installScripts["pyproject.toml"] = "in-tree build backend (backend-path)";
427
+ add(caps, "install-scripts", { file: `${prefix}pyproject.toml`, line: findLine(text, "backend-path"), snippet: "in-tree build backend (backend-path)" });
428
+ }
429
+ }
430
+
431
+ for (const v of Object.values(caps)) v.evidence.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
432
+ const ordered = {};
433
+ for (const id of CAPABILITY_ORDER) if (caps[id]) ordered[id] = caps[id];
434
+
435
+ return {
436
+ name: null,
437
+ version: null,
438
+ capabilities: ordered,
439
+ installScripts,
440
+ installRisk: [...new Set(installRisk)],
441
+ exfil,
442
+ filesScanned,
443
+ skipped,
444
+ startupHooks,
445
+ modified,
446
+ };
447
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Python dependency trees for `penthera deps`.
3
+ *
4
+ * Loads what a Python project depends on (a lockfile or requirements file, a
5
+ * virtualenv, or both), finds each package's files offline (the venv, pip's
6
+ * and uv's caches, local path dependencies) and scans them with the Python
7
+ * capability scanner. lib/deps/index.js turns the result into findings,
8
+ * diffs and reports exactly as it does for npm.
9
+ */
10
+ import { existsSync, readFileSync, statSync, realpathSync } from "node:fs";
11
+ import { join, relative, resolve, dirname, basename } from "node:path";
12
+ import { readPythonInputs, readPythonInputsAtRef, parsePythonInput, findPythonInputs, normalizeName, looksLikePythonProject } from "./lockfiles.js";
13
+ import { findVenv, sitePackagesDirs, listInstalled, sourceFromPipCache, sourceFromUvCache, sourceFromLocalPath, pipCacheDir, uvCacheDir } from "./source.js";
14
+ import { scanPythonPackage, pthImportLines, scanPythonSource } from "./capabilities.js";
15
+ import { parseToml } from "./toml.js";
16
+ import { RISKY_FOR_INSTALL } from "../capabilities.js";
17
+
18
+ export { looksLikePythonProject };
19
+
20
+ /** True when a folder has something `penthera deps` can read for Python. */
21
+ export function hasPythonInputs(root, venvOpt) {
22
+ return !!(findPythonInputs(root).lockfile || venvOpt || findVenv(root, null));
23
+ }
24
+
25
+ const relTo = (root, p) => {
26
+ const r = relative(root, p).split("\\").join("/");
27
+ return r === "" ? "." : r;
28
+ };
29
+ /** Display path: relative to the working directory unless that climbs out of it. */
30
+ const display = (p) => {
31
+ const r = relTo(process.cwd(), p);
32
+ return r.startsWith("..") ? p : r;
33
+ };
34
+
35
+ /** The project's own name (so a venv-only scan does not list the project as a dependency). */
36
+ function projectName(root) {
37
+ try {
38
+ const t = parseToml(readFileSync(join(root, "pyproject.toml"), "utf-8"));
39
+ const n = t.project?.name || t.tool?.poetry?.name;
40
+ return n ? normalizeName(n) : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** Every installed distribution in a venv, with Requires-Dist names. */
47
+ function installedDists(venv, root) {
48
+ const dists = [];
49
+ const unownedPth = [];
50
+ // site-packages paths are real paths; compare like with like (macOS /var -> /private/var).
51
+ try { root = realpathSync(root); } catch { /* keep as given */ }
52
+ for (const site of sitePackagesDirs(venv)) {
53
+ const list = listInstalled(site);
54
+ const r = relTo(root, site);
55
+ const prefix = `${r.startsWith("..") ? site : r}/`;
56
+ for (const d of list) {
57
+ let requires = [];
58
+ try {
59
+ const md = readFileSync(join(site, d.distInfo, d.distInfo.endsWith(".dist-info") ? "METADATA" : "PKG-INFO"), "utf-8");
60
+ const head = md.replace(/\r\n/g, "\n").split("\n\n")[0];
61
+ requires = [...head.matchAll(/^Requires-Dist:\s*([A-Za-z0-9][A-Za-z0-9._-]*)([^\n]*)$/gm)]
62
+ .filter((m) => !/extra\s*==/.test(m[2])).map((m) => normalizeName(m[1]));
63
+ } catch { /* no metadata */ }
64
+ dists.push({ ...d, site, prefix, requires });
65
+ }
66
+ for (const f of list.unownedPth) unownedPth.push({ site, prefix, file: f });
67
+ }
68
+ return { dists, unownedPth };
69
+ }
70
+
71
+ /** Distributions nobody else requires: the closest thing a bare venv has to direct dependencies. */
72
+ function venvRoots(dists) {
73
+ const required = new Set(dists.flatMap((d) => d.requires));
74
+ return dists.filter((d) => !required.has(d.name)).map((d) => d.name);
75
+ }
76
+
77
+ /**
78
+ * Scan one Python package list. Results are cached by name@version+hashes.
79
+ * @param {object[]} pkgs from the lockfile reader (or venv listing)
80
+ * @param {{ dists: object[] }|null} env installed distributions to read from
81
+ */
82
+ function scanPyTree(pkgs, env, ctx) {
83
+ const installed = new Map();
84
+ for (const d of env?.dists || []) {
85
+ if (!installed.has(d.name)) installed.set(d.name, d);
86
+ }
87
+ return pkgs.map((p) => {
88
+ const inst = installed.get(p.name);
89
+ const version = p.version || inst?.version || null;
90
+ const key = `${p.name}@${version}#${p.hashes.join(",")}#${p.local || ""}#${env?.id || ""}`;
91
+ if (!ctx.cache.has(key)) {
92
+ let src = null;
93
+ let prefix = `${p.name}-${version}/`;
94
+ if (p.local && existsSync(p.local)) {
95
+ src = sourceFromLocalPath(p.local);
96
+ prefix = `${relTo(ctx.root, p.local)}/`;
97
+ } else if (inst && (!p.version || inst.version === p.version)) {
98
+ src = inst.source;
99
+ prefix = inst.prefix;
100
+ } else if (version) {
101
+ const q = { ...p, version };
102
+ src = sourceFromPipCache(q, ctx.pipCache) || sourceFromUvCache(q, ctx.uvCache);
103
+ }
104
+ ctx.cache.set(key, src ? { ...scanPythonPackage(src, { prefix }), source: src.kind } : null);
105
+ }
106
+ const r = ctx.cache.get(key);
107
+ return {
108
+ ...p,
109
+ version,
110
+ source: r?.source || null,
111
+ capabilities: r ? r.capabilities : null,
112
+ installScripts: r?.installScripts || {},
113
+ installRisk: r?.installRisk || [],
114
+ exfil: r?.exfil || [],
115
+ startupHooks: r?.startupHooks || [],
116
+ modified: r?.modified || [],
117
+ filesScanned: r?.filesScanned || 0,
118
+ };
119
+ });
120
+ }
121
+
122
+ /** Python inputs for a path passed to --diff: a folder, a lockfile or a requirements file. */
123
+ function inputsFromPath(p) {
124
+ if (statSync(p).isDirectory()) return readPythonInputs(p);
125
+ const name = basename(p);
126
+ const kind = ["uv.lock", "poetry.lock", "pdm.lock", "Pipfile.lock"].includes(name) ? name : "requirements.txt";
127
+ return { ...parsePythonInput(readFileSync(p, "utf-8"), kind, dirname(p), name), file: p };
128
+ }
129
+
130
+ /** Packages from a venv listing, shaped like lockfile entries. */
131
+ function packagesFromVenv(env, root, skipName) {
132
+ return env.dists.filter((d) => d.name !== skipName).map((d) => ({
133
+ name: d.name,
134
+ displayName: d.displayName,
135
+ version: d.version,
136
+ path: `${d.prefix}${d.distInfo}`,
137
+ hashes: [],
138
+ files: [],
139
+ dev: false,
140
+ optional: false,
141
+ local: null,
142
+ link: false,
143
+ ecosystem: "pypi",
144
+ }));
145
+ }
146
+
147
+ /** Findings-ready info about .pth files no installed distribution owns. */
148
+ function unownedStartupHooks(env) {
149
+ const out = [];
150
+ for (const u of env?.unownedPth || []) {
151
+ let text;
152
+ try { text = readFileSync(join(u.site, u.file), "utf-8"); } catch { continue; }
153
+ for (const l of pthImportLines(text)) {
154
+ const caps = Object.keys(scanPythonSource(l.text, `${u.prefix}${u.file}`).caps).filter((c) => RISKY_FOR_INSTALL.includes(c) || c === "shell");
155
+ out.push({ file: `${u.prefix}${u.file}`, line: l.line, snippet: l.text.slice(0, 160), risk: caps });
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * Load and scan the Python tree of a project.
163
+ * @returns {{ lockfile, format, lockfileVersion, tree, direct, venv, unownedHooks, ctx }}
164
+ */
165
+ export function loadPythonTree(root, opts = {}) {
166
+ const inputs = readPythonInputs(root);
167
+ const venv = findVenv(root, opts.venv || null);
168
+ if (!inputs && !venv) {
169
+ throw new Error(`No Python lockfile, requirements*.txt or virtualenv in ${root}. penthera deps reads uv.lock, poetry.lock, pdm.lock, Pipfile.lock or requirements*.txt, or an installed .venv (pass another with --venv).`);
170
+ }
171
+ const env = venv ? { id: venv, ...installedDists(venv, root) } : null;
172
+ const ctx = {
173
+ root,
174
+ cache: new Map(),
175
+ pipCache: opts.pipCacheDir || pipCacheDir(),
176
+ uvCache: opts.uvCacheDir || uvCacheDir(),
177
+ };
178
+ const self = projectName(root);
179
+ const pkgs = inputs ? inputs.packages : packagesFromVenv(env, root, self);
180
+ const tree = scanPyTree(pkgs, env, ctx);
181
+ let direct = inputs?.direct || [];
182
+ if (!direct.length && env) direct = venvRoots(env.dists.filter((d) => d.name !== self));
183
+ const format = inputs ? `${inputs.kind}${inputs.version != null ? ` v${inputs.version}` : ""}${venv ? " + venv" : ""}` : "virtualenv";
184
+ return {
185
+ lockfile: display(inputs ? inputs.file : venv),
186
+ lockfiles: inputs?.files?.map(display) || null,
187
+ format,
188
+ lockfileVersion: inputs?.version ?? null,
189
+ venv: venv ? display(venv) : null,
190
+ tree,
191
+ direct,
192
+ unownedHooks: unownedStartupHooks(env),
193
+ ctx,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Load and scan the base tree for --diff (path) or --base (git ref), reusing
199
+ * scans of versions both trees share. Old versions come from the base
200
+ * folder's venv when --diff points at one, else pip's or uv's cache by hash.
201
+ */
202
+ export function loadPythonBase(root, opts, current) {
203
+ let inputs = null;
204
+ let env = null;
205
+ let label;
206
+ if (opts.base) {
207
+ inputs = readPythonInputsAtRef(root, opts.base);
208
+ label = opts.base;
209
+ // No Python inputs at the base: every Python package is new in this change.
210
+ if (!inputs) return { tree: [], label, needOld: 0 };
211
+ } else {
212
+ const p = resolve(opts.diff);
213
+ if (!existsSync(p)) throw new Error(`--diff path not found: ${opts.diff}`);
214
+ inputs = inputsFromPath(p);
215
+ const dir = statSync(p).isDirectory() ? p : dirname(p);
216
+ // A venv beside the project's own lockfile is the new tree, not the old one.
217
+ const venv = resolve(dir) === resolve(root) ? null : findVenv(dir, null);
218
+ if (venv) env = { id: venv, ...installedDists(venv, root) };
219
+ label = opts.diff;
220
+ if (!inputs && !env) return { tree: [], label, needOld: 0 };
221
+ }
222
+ const oldPkgs = inputs ? inputs.packages : packagesFromVenv(env, root, projectName(root));
223
+ const byVersion = new Map(current.tree.map((p) => [`${p.name}@${p.version}`, p]));
224
+ const newNames = new Set(current.tree.map((p) => p.name));
225
+ const needOld = oldPkgs.filter((p) => !byVersion.has(`${p.name}@${p.version}`) && newNames.has(p.name));
226
+ const scanned = new Map(scanPyTree(needOld, env, current.ctx).map((p) => [`${p.name}@${p.version}`, p]));
227
+ const tree = oldPkgs.map((p) => {
228
+ const k = `${p.name}@${p.version}`;
229
+ if (scanned.has(k)) return scanned.get(k);
230
+ const same = byVersion.get(k);
231
+ return { ...p, capabilities: same?.capabilities || {}, installScripts: same?.installScripts || {} };
232
+ });
233
+ return { tree, label, needOld: needOld.length };
234
+ }