openhack 0.1.0__py3-none-any.whl

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 (113) hide show
  1. openhack/__init__.py +2 -0
  2. openhack/__main__.py +225 -0
  3. openhack/agents/__init__.py +30 -0
  4. openhack/agents/base.py +230 -0
  5. openhack/agents/browser_verifier.py +679 -0
  6. openhack/agents/browser_verifier_swarm.py +256 -0
  7. openhack/agents/checkpoint.py +89 -0
  8. openhack/agents/context_manager.py +356 -0
  9. openhack/agents/coordinator.py +1105 -0
  10. openhack/agents/endpoint_analyst.py +307 -0
  11. openhack/agents/feature_hunter.py +93 -0
  12. openhack/agents/hunter.py +481 -0
  13. openhack/agents/hunter_swarm.py +385 -0
  14. openhack/agents/llm.py +334 -0
  15. openhack/agents/recon.py +19 -0
  16. openhack/agents/sandbox_verifier.py +396 -0
  17. openhack/agents/sandbox_verifier_swarm.py +250 -0
  18. openhack/agents/session.py +286 -0
  19. openhack/agents/validator.py +217 -0
  20. openhack/agents/validator_swarm.py +106 -0
  21. openhack/auth.py +175 -0
  22. openhack/browser/__init__.py +12 -0
  23. openhack/browser/runner.py +385 -0
  24. openhack/categories.py +130 -0
  25. openhack/config.py +201 -0
  26. openhack/deterministic_recon.py +464 -0
  27. openhack/entry_points.py +745 -0
  28. openhack/framework_classifier.py +515 -0
  29. openhack/framework_detection.py +269 -0
  30. openhack/headless_scan.py +179 -0
  31. openhack/prompts/__init__.py +108 -0
  32. openhack/prompts/browser_verifier.py +171 -0
  33. openhack/prompts/coordinator.py +31 -0
  34. openhack/prompts/django/__init__.py +32 -0
  35. openhack/prompts/django/auth_bypass.py +76 -0
  36. openhack/prompts/django/csrf.py +62 -0
  37. openhack/prompts/django/data_exposure.py +67 -0
  38. openhack/prompts/django/idor.py +74 -0
  39. openhack/prompts/django/injection.py +67 -0
  40. openhack/prompts/django/misconfiguration.py +70 -0
  41. openhack/prompts/django/ssrf.py +64 -0
  42. openhack/prompts/endpoint_analyst.py +122 -0
  43. openhack/prompts/express/__init__.py +29 -0
  44. openhack/prompts/express/auth_bypass.py +71 -0
  45. openhack/prompts/express/data_exposure.py +77 -0
  46. openhack/prompts/express/idor.py +69 -0
  47. openhack/prompts/express/injection.py +75 -0
  48. openhack/prompts/express/misconfiguration.py +72 -0
  49. openhack/prompts/express/ssrf.py +63 -0
  50. openhack/prompts/feature_hunter.py +140 -0
  51. openhack/prompts/flask/__init__.py +29 -0
  52. openhack/prompts/flask/auth_bypass.py +86 -0
  53. openhack/prompts/flask/data_exposure.py +78 -0
  54. openhack/prompts/flask/idor.py +83 -0
  55. openhack/prompts/flask/injection.py +77 -0
  56. openhack/prompts/flask/misconfiguration.py +73 -0
  57. openhack/prompts/flask/ssrf.py +65 -0
  58. openhack/prompts/hunter.py +362 -0
  59. openhack/prompts/hunter_continuation_loop.py +12 -0
  60. openhack/prompts/hunter_continuation_no_findings.py +19 -0
  61. openhack/prompts/hunter_continuation_no_progress.py +22 -0
  62. openhack/prompts/hunter_tool_instructions.py +55 -0
  63. openhack/prompts/nextjs/__init__.py +42 -0
  64. openhack/prompts/nextjs/auth_bypass.py +80 -0
  65. openhack/prompts/nextjs/csrf.py +71 -0
  66. openhack/prompts/nextjs/data_exposure.py +88 -0
  67. openhack/prompts/nextjs/idor.py +64 -0
  68. openhack/prompts/nextjs/injection.py +65 -0
  69. openhack/prompts/nextjs/middleware_bypass.py +75 -0
  70. openhack/prompts/nextjs/misconfiguration.py +92 -0
  71. openhack/prompts/nextjs/server_actions.py +97 -0
  72. openhack/prompts/nextjs/ssrf.py +66 -0
  73. openhack/prompts/nextjs/xss.py +69 -0
  74. openhack/prompts/pr_analysis_system.py +80 -0
  75. openhack/prompts/pr_analysis_user.py +11 -0
  76. openhack/prompts/project_context.py +89 -0
  77. openhack/prompts/recon.py +199 -0
  78. openhack/prompts/reporter.py +88 -0
  79. openhack/prompts/researchers.py +434 -0
  80. openhack/prompts/sandbox_verifier.py +128 -0
  81. openhack/prompts/supabase/__init__.py +39 -0
  82. openhack/prompts/supabase/auth_tokens.py +131 -0
  83. openhack/prompts/supabase/edge_functions.py +150 -0
  84. openhack/prompts/supabase/graphql.py +102 -0
  85. openhack/prompts/supabase/postgrest.py +99 -0
  86. openhack/prompts/supabase/realtime.py +93 -0
  87. openhack/prompts/supabase/rls.py +110 -0
  88. openhack/prompts/supabase/rpc_functions.py +127 -0
  89. openhack/prompts/supabase/storage.py +110 -0
  90. openhack/prompts/supabase/tenant_isolation.py +118 -0
  91. openhack/prompts/validator.py +319 -0
  92. openhack/prompts/validator_continuation_incomplete.py +12 -0
  93. openhack/prompts/validator_tool_instructions.py +29 -0
  94. openhack/quality.py +231 -0
  95. openhack/sandbox/__init__.py +12 -0
  96. openhack/sandbox/orchestrator.py +517 -0
  97. openhack/sandbox/runner.py +177 -0
  98. openhack/scan_session.py +245 -0
  99. openhack/setup.py +452 -0
  100. openhack/static_validator.py +612 -0
  101. openhack/tools/__init__.py +1 -0
  102. openhack/tools/ast_tools.py +307 -0
  103. openhack/tools/coverage.py +1078 -0
  104. openhack/tools/filesystem.py +404 -0
  105. openhack/tools/nextjs.py +258 -0
  106. openhack/tools/registry.py +52 -0
  107. openhack/tui.py +3450 -0
  108. openhack/updates.py +170 -0
  109. openhack-0.1.0.dist-info/METADATA +189 -0
  110. openhack-0.1.0.dist-info/RECORD +113 -0
  111. openhack-0.1.0.dist-info/WHEEL +4 -0
  112. openhack-0.1.0.dist-info/entry_points.txt +2 -0
  113. openhack-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,517 @@
1
+ """
2
+ Docker sandbox orchestrator.
3
+
4
+ Manages the lifecycle of target applications in isolated Docker containers:
5
+ - Detects docker-compose.yml / Dockerfile in the target repo
6
+ - Builds and starts the application
7
+ - Waits for health check
8
+ - Provides the base URL for exploit execution
9
+ - Tears down containers on completion
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+ import shutil
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class SandboxConfig:
25
+ """Configuration for the sandbox environment."""
26
+ # How to start the app
27
+ build_command: Optional[str] = None # e.g. "docker-compose up --build -d"
28
+ compose_file: Optional[str] = None # path to docker-compose.yml relative to target
29
+ dockerfile: Optional[str] = None # path to Dockerfile relative to target
30
+
31
+ # Health check
32
+ health_check_url: Optional[str] = None # e.g. "http://localhost:3000/api/health"
33
+ health_check_port: int = 3000
34
+ health_check_path: str = "/"
35
+ health_check_timeout: int = 120 # seconds to wait for app to be ready
36
+
37
+ # Environment
38
+ env_vars: dict[str, str] = field(default_factory=dict)
39
+
40
+ # Network isolation
41
+ network_mode: str = "bridge" # "bridge" or "none" for full isolation
42
+ host_port: int = 0 # 0 = auto-assign
43
+
44
+ # Cleanup
45
+ teardown_on_complete: bool = True
46
+
47
+
48
+ @dataclass
49
+ class SandboxStatus:
50
+ """Current status of the sandbox."""
51
+ running: bool = False
52
+ base_url: str = ""
53
+ container_ids: list[str] = field(default_factory=list)
54
+ project_name: str = ""
55
+ start_time: float = 0.0
56
+ host_port: int = 0
57
+
58
+
59
+ class SandboxOrchestrator:
60
+ """Manages Docker sandbox lifecycle for exploit verification."""
61
+
62
+ def __init__(self, target_dir: Path, config: Optional[SandboxConfig] = None):
63
+ self.target_dir = target_dir.resolve()
64
+ self.config = config or SandboxConfig()
65
+ self.status = SandboxStatus()
66
+ self._project_name = f"openhack-sandbox-{int(time.time())}"
67
+
68
+ async def start(self) -> SandboxStatus:
69
+ """Start the target application in a Docker sandbox."""
70
+ logger.info(f"Starting sandbox for {self.target_dir}")
71
+
72
+ # Auto-detect how to start the app
73
+ compose_file = self._find_compose_file()
74
+ dockerfile = self._find_dockerfile()
75
+
76
+ if not compose_file and not dockerfile:
77
+ raise SandboxError(
78
+ f"No docker-compose.yml or Dockerfile found in {self.target_dir}. "
79
+ "Cannot start sandbox without containerization config."
80
+ )
81
+
82
+ # Assign a host port
83
+ host_port = self.config.host_port or await self._find_free_port()
84
+ self.status.host_port = host_port
85
+
86
+ try:
87
+ if compose_file:
88
+ await self._start_with_compose(compose_file, host_port)
89
+ else:
90
+ await self._start_with_dockerfile(dockerfile, host_port)
91
+ except SandboxError:
92
+ await self._force_cleanup_network()
93
+ raise
94
+
95
+ # Wait for health check
96
+ base_url = f"http://localhost:{host_port}"
97
+ self.status.base_url = base_url
98
+
99
+ health_url = self.config.health_check_url or f"{base_url}{self.config.health_check_path}"
100
+ try:
101
+ await self._wait_for_health(health_url)
102
+ except SandboxError:
103
+ self.status.running = True
104
+ await self.stop()
105
+ raise
106
+
107
+ self.status.running = True
108
+ self.status.start_time = time.time()
109
+ logger.info(f"Sandbox ready at {base_url}")
110
+ return self.status
111
+
112
+ async def stop(self) -> None:
113
+ """Tear down the sandbox containers and clean up Docker resources."""
114
+ if not self.status.running:
115
+ return
116
+
117
+ logger.info(f"Stopping sandbox {self._project_name}")
118
+ try:
119
+ compose_file = self._find_compose_file()
120
+ if compose_file:
121
+ cmd = [
122
+ "docker", "compose", "-p", self._project_name,
123
+ "-f", str(compose_file),
124
+ ]
125
+ override = getattr(self, '_override_file', None)
126
+ if override and override.exists():
127
+ cmd.extend(["-f", str(override)])
128
+ cmd.extend(["down", "-v", "--remove-orphans"])
129
+
130
+ await self._run_command(cmd, timeout=30)
131
+
132
+ # Clean up override file
133
+ if override and override.exists():
134
+ override.unlink()
135
+ else:
136
+ for cid in self.status.container_ids:
137
+ await self._run_command(
138
+ ["docker", "rm", "-f", cid], timeout=15,
139
+ )
140
+ except Exception as e:
141
+ logger.warning(f"Error during sandbox teardown: {e}")
142
+ await self._force_cleanup_network()
143
+ finally:
144
+ self.status.running = False
145
+ self.status.container_ids = []
146
+ logger.info("Sandbox stopped")
147
+
148
+ async def _force_cleanup_network(self) -> None:
149
+ """Remove the Docker network for this project if it still exists."""
150
+ network_name = f"{self._project_name}_default"
151
+ try:
152
+ await self._run_command(
153
+ ["docker", "network", "rm", network_name], timeout=10,
154
+ )
155
+ logger.info(f"Cleaned up stale network: {network_name}")
156
+ except Exception:
157
+ pass
158
+
159
+ async def __aenter__(self):
160
+ await self.start()
161
+ return self
162
+
163
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
164
+ if self.config.teardown_on_complete:
165
+ await self.stop()
166
+
167
+ def _find_compose_file(self) -> Optional[Path]:
168
+ """Find docker-compose file in the target directory."""
169
+ if self.config.compose_file:
170
+ path = self.target_dir / self.config.compose_file
171
+ return path if path.exists() else None
172
+
173
+ for name in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
174
+ path = self.target_dir / name
175
+ if path.exists():
176
+ return path
177
+ return None
178
+
179
+ def _find_dockerfile(self) -> Optional[Path]:
180
+ """Find Dockerfile in the target directory."""
181
+ if self.config.dockerfile:
182
+ path = self.target_dir / self.config.dockerfile
183
+ return path if path.exists() else None
184
+
185
+ path = self.target_dir / "Dockerfile"
186
+ return path if path.exists() else None
187
+
188
+ async def _find_free_port(self) -> int:
189
+ """Find a free port on the host."""
190
+ import socket
191
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
192
+ s.bind(("", 0))
193
+ return s.getsockname()[1]
194
+
195
+ async def _start_with_compose(self, compose_file: Path, host_port: int) -> None:
196
+ """Start the app using docker-compose.
197
+
198
+ Generates an override file that:
199
+ - Removes hardcoded container_name directives (project name handles naming)
200
+ - Remaps the app port to an available host port
201
+ - Remaps any other ports that might conflict
202
+ """
203
+ self.status.project_name = self._project_name
204
+ self._override_file: Optional[Path] = None
205
+
206
+ # Parse compose file to find the app service and generate overrides
207
+ override = await self._generate_compose_override(compose_file, host_port)
208
+
209
+ env = {**self.config.env_vars, "HOST_PORT": str(host_port)}
210
+
211
+ # Write override file
212
+ override_path = self.target_dir / f".openhack-sandbox-override-{self._project_name}.yml"
213
+ override_path.write_text(override)
214
+ self._override_file = override_path
215
+
216
+ # Build and start with override
217
+ cmd = [
218
+ "docker", "compose",
219
+ "-p", self._project_name,
220
+ "-f", str(compose_file),
221
+ "-f", str(override_path),
222
+ "up", "--build", "-d",
223
+ ]
224
+
225
+ logger.info(f"Starting with compose (port {host_port}): {' '.join(cmd)}")
226
+ await self._run_command(cmd, cwd=self.target_dir, env=env, timeout=300)
227
+
228
+ # Get container IDs
229
+ ps_cmd = [
230
+ "docker", "compose", "-p", self._project_name,
231
+ "-f", str(compose_file),
232
+ "-f", str(override_path),
233
+ "ps", "-q",
234
+ ]
235
+ result = await self._run_command(ps_cmd, cwd=self.target_dir, timeout=10)
236
+ self.status.container_ids = [
237
+ cid.strip() for cid in result.stdout.strip().split("\n") if cid.strip()
238
+ ]
239
+
240
+ async def _generate_compose_override(self, compose_file: Path, host_port: int) -> str:
241
+ """Generate a docker-compose override that remaps ports and removes container names.
242
+
243
+ Parses the compose file with a simple line-based approach to avoid
244
+ requiring pyyaml as a dependency.
245
+ """
246
+ compose_text = compose_file.read_text()
247
+
248
+ # Extract service names and their port mappings
249
+ services = self._parse_compose_services(compose_text)
250
+
251
+ override: dict = {"services": {}}
252
+
253
+ for svc_name, svc_info in services.items():
254
+ svc_override: dict = {}
255
+
256
+ if svc_info.get("container_name"):
257
+ svc_override["container_name"] = f"{self._project_name}-{svc_name}"
258
+
259
+ ports = svc_info.get("ports", [])
260
+ if ports:
261
+ new_ports = []
262
+ for port_mapping in ports:
263
+ if ":" in port_mapping:
264
+ parts = port_mapping.strip('"').strip("'").split(":")
265
+ container_port = parts[-1]
266
+ if container_port == str(self.config.health_check_port):
267
+ new_ports.append(f"{host_port}:{container_port}")
268
+ else:
269
+ free = await self._find_free_port()
270
+ new_ports.append(f"{free}:{container_port}")
271
+ else:
272
+ new_ports.append(port_mapping)
273
+ svc_override["ports"] = new_ports
274
+
275
+ if svc_override:
276
+ override["services"][svc_name] = svc_override
277
+
278
+ return self._dict_to_yaml(override, override_lists=True)
279
+
280
+ @staticmethod
281
+ def _parse_compose_services(text: str) -> dict:
282
+ """Minimally parse a docker-compose file to extract service info.
283
+
284
+ Returns {service_name: {"container_name": str|None, "ports": [str]}}.
285
+ """
286
+ services = {}
287
+ current_service = None
288
+ in_services = False
289
+ in_ports = False
290
+ services_indent = None
291
+ service_indent = None
292
+ prop_indent = None
293
+
294
+ for line in text.splitlines():
295
+ stripped = line.strip()
296
+ if not stripped or stripped.startswith("#"):
297
+ continue
298
+
299
+ indent = len(line) - len(line.lstrip())
300
+
301
+ # Find the top-level "services:" key
302
+ if stripped == "services:" and indent == 0:
303
+ in_services = True
304
+ services_indent = indent
305
+ continue
306
+
307
+ # Exited services block
308
+ if in_services and indent == 0 and stripped and not stripped.startswith("-"):
309
+ in_services = False
310
+ current_service = None
311
+ continue
312
+
313
+ if not in_services:
314
+ continue
315
+
316
+ # Service name: first level of indent under services (typically 2 spaces)
317
+ if stripped.endswith(":") and ":" not in stripped[:-1]:
318
+ if service_indent is None or indent <= service_indent:
319
+ name = stripped.rstrip(":")
320
+ current_service = name
321
+ service_indent = indent
322
+ services[current_service] = {"container_name": None, "ports": []}
323
+ in_ports = False
324
+ prop_indent = None
325
+ continue
326
+
327
+ if current_service is None:
328
+ continue
329
+
330
+ # Service properties are one level deeper than service name
331
+ if prop_indent is None and indent > service_indent:
332
+ prop_indent = indent
333
+
334
+ # If we're back to service-level indent, it's a new service
335
+ if indent <= service_indent and stripped.endswith(":") and ":" not in stripped[:-1]:
336
+ name = stripped.rstrip(":")
337
+ current_service = name
338
+ services[current_service] = {"container_name": None, "ports": []}
339
+ in_ports = False
340
+ prop_indent = None
341
+ continue
342
+
343
+ if prop_indent is not None and indent == prop_indent:
344
+ in_ports = False
345
+ if stripped.startswith("container_name:"):
346
+ val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
347
+ services[current_service]["container_name"] = val
348
+ elif stripped == "ports:":
349
+ in_ports = True
350
+
351
+ elif in_ports and stripped.startswith("-"):
352
+ port = stripped.lstrip("- ").strip('"').strip("'")
353
+ services[current_service]["ports"].append(port)
354
+
355
+ return services
356
+
357
+ @staticmethod
358
+ def _dict_to_yaml(d: dict, indent: int = 0, override_lists: bool = False) -> str:
359
+ """Minimal dict-to-YAML serializer (avoids pyyaml dependency).
360
+
361
+ When override_lists=True, list keys emit `!override` so Docker Compose
362
+ replaces the base list instead of merging into it.
363
+ """
364
+ lines = []
365
+ prefix = " " * indent
366
+ for key, val in d.items():
367
+ if isinstance(val, dict):
368
+ lines.append(f"{prefix}{key}:")
369
+ lines.append(SandboxOrchestrator._dict_to_yaml(val, indent + 1, override_lists))
370
+ elif isinstance(val, list):
371
+ tag = " !override" if override_lists else ""
372
+ lines.append(f"{prefix}{key}:{tag}")
373
+ for item in val:
374
+ if isinstance(item, dict):
375
+ items = list(item.items())
376
+ first_key, first_val = items[0]
377
+ lines.append(f"{prefix} - {first_key}: {first_val}")
378
+ for k, v in items[1:]:
379
+ lines.append(f"{prefix} {k}: {v}")
380
+ else:
381
+ lines.append(f"{prefix} - \"{item}\"" if isinstance(item, str) else f"{prefix} - {item}")
382
+ else:
383
+ if isinstance(val, str):
384
+ lines.append(f"{prefix}{key}: \"{val}\"")
385
+ else:
386
+ lines.append(f"{prefix}{key}: {val}")
387
+ return "\n".join(lines)
388
+
389
+ async def _start_with_dockerfile(self, dockerfile: Path, host_port: int) -> None:
390
+ """Start the app by building a Dockerfile and running the container."""
391
+ image_name = f"openhack-sandbox:{self._project_name}"
392
+
393
+ # Build
394
+ build_cmd = [
395
+ "docker", "build",
396
+ "-t", image_name,
397
+ "-f", str(dockerfile),
398
+ str(self.target_dir),
399
+ ]
400
+ logger.info(f"Building image: {image_name}")
401
+ await self._run_command(build_cmd, timeout=300)
402
+
403
+ # Run
404
+ app_port = self.config.health_check_port
405
+ run_cmd = [
406
+ "docker", "run", "-d",
407
+ "--name", self._project_name,
408
+ "-p", f"{host_port}:{app_port}",
409
+ ]
410
+
411
+ for k, v in self.config.env_vars.items():
412
+ run_cmd.extend(["-e", f"{k}={v}"])
413
+
414
+ run_cmd.append(image_name)
415
+
416
+ logger.info(f"Running container: {self._project_name}")
417
+ result = await self._run_command(run_cmd, timeout=30)
418
+ container_id = result.stdout.strip()
419
+ self.status.container_ids = [container_id]
420
+
421
+ async def _wait_for_health(self, health_url: str) -> None:
422
+ """Poll the health check URL until the app is ready."""
423
+ import aiohttp
424
+
425
+ timeout = self.config.health_check_timeout
426
+ start = time.time()
427
+ last_error = None
428
+
429
+ logger.info(f"Waiting for health check: {health_url} (timeout: {timeout}s)")
430
+
431
+ while time.time() - start < timeout:
432
+ try:
433
+ async with aiohttp.ClientSession() as session:
434
+ async with session.get(health_url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
435
+ if resp.status < 500:
436
+ elapsed = time.time() - start
437
+ logger.info(f"Health check passed (status {resp.status}) in {elapsed:.1f}s")
438
+ return
439
+ last_error = f"HTTP {resp.status}"
440
+ except Exception as e:
441
+ last_error = str(e)
442
+
443
+ await asyncio.sleep(2)
444
+
445
+ # Pull the last few lines of container logs so the error surfaces
446
+ # *why* the app failed (Prisma schema missing, port conflict, etc.)
447
+ # instead of just "HTTP 500".
448
+ tail_logs = ""
449
+ try:
450
+ tail_logs = await self.get_logs(tail=40)
451
+ except Exception:
452
+ pass
453
+ log_snippet = (
454
+ "\n— Recent container logs —\n" + tail_logs.strip()[-2000:]
455
+ if tail_logs.strip() else ""
456
+ )
457
+ raise SandboxError(
458
+ f"Health check failed after {timeout}s. "
459
+ f"URL: {health_url}, last error: {last_error}{log_snippet}"
460
+ )
461
+
462
+ async def get_logs(self, tail: int = 100) -> str:
463
+ """Get container logs for debugging."""
464
+ logs = []
465
+ for cid in self.status.container_ids:
466
+ try:
467
+ result = await self._run_command(
468
+ ["docker", "logs", "--tail", str(tail), cid], timeout=10,
469
+ )
470
+ logs.append(f"=== Container {cid[:12]} ===\n{result.stdout}")
471
+ if result.stderr:
472
+ logs.append(f"STDERR:\n{result.stderr}")
473
+ except Exception as e:
474
+ logs.append(f"=== Container {cid[:12]} === ERROR: {e}")
475
+ return "\n".join(logs)
476
+
477
+ @staticmethod
478
+ async def _run_command(
479
+ cmd: list[str],
480
+ cwd: Optional[Path] = None,
481
+ env: Optional[dict] = None,
482
+ timeout: int = 60,
483
+ ) -> asyncio.subprocess.Process:
484
+ """Run a shell command asynchronously."""
485
+ import os
486
+ full_env = {**os.environ, **(env or {})}
487
+
488
+ proc = await asyncio.create_subprocess_exec(
489
+ *cmd,
490
+ stdout=asyncio.subprocess.PIPE,
491
+ stderr=asyncio.subprocess.PIPE,
492
+ cwd=cwd,
493
+ env=full_env,
494
+ )
495
+
496
+ try:
497
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
498
+ except asyncio.TimeoutError:
499
+ proc.kill()
500
+ await proc.communicate()
501
+ raise SandboxError(f"Command timed out after {timeout}s: {' '.join(cmd)}")
502
+
503
+ proc.stdout = stdout.decode() if stdout else ""
504
+ proc.stderr = stderr.decode() if stderr else ""
505
+
506
+ if proc.returncode != 0:
507
+ raise SandboxError(
508
+ f"Command failed (exit {proc.returncode}): {' '.join(cmd)}\n"
509
+ f"STDERR: {proc.stderr[:2000]}"
510
+ )
511
+
512
+ return proc
513
+
514
+
515
+ class SandboxError(Exception):
516
+ """Raised when sandbox operations fail."""
517
+ pass
@@ -0,0 +1,177 @@
1
+ """
2
+ Exploit runner for sandbox verification.
3
+
4
+ Executes HTTP requests and scripts against a running sandboxed application.
5
+ Returns full response details so the agent can analyze whether the exploit worked.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Optional
14
+
15
+ import aiohttp
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class ExploitResult:
22
+ """Result of an exploit attempt."""
23
+ success: bool
24
+ status_code: Optional[int] = None
25
+ headers: dict[str, str] = field(default_factory=dict)
26
+ body: str = ""
27
+ elapsed_ms: float = 0.0
28
+ error: Optional[str] = None
29
+ attempt: int = 1
30
+
31
+ def to_dict(self) -> dict:
32
+ d = {
33
+ "success": self.success,
34
+ "status_code": self.status_code,
35
+ "elapsed_ms": round(self.elapsed_ms, 1),
36
+ "attempt": self.attempt,
37
+ }
38
+ if self.headers:
39
+ # Only include security-relevant headers
40
+ relevant = {}
41
+ for k, v in self.headers.items():
42
+ kl = k.lower()
43
+ if kl in (
44
+ "content-type", "set-cookie", "location",
45
+ "x-powered-by", "server", "access-control-allow-origin",
46
+ "www-authenticate", "x-frame-options",
47
+ "content-security-policy", "x-content-type-options",
48
+ ):
49
+ relevant[k] = v
50
+ d["headers"] = relevant
51
+ if self.body:
52
+ d["body"] = self.body[:5000] # Cap body size for context window
53
+ if self.error:
54
+ d["error"] = self.error
55
+ return d
56
+
57
+
58
+ class ExploitRunner:
59
+ """Executes exploit requests against a sandboxed application."""
60
+
61
+ def __init__(self, base_url: str, timeout: int = 30):
62
+ self.base_url = base_url.rstrip("/")
63
+ self.timeout = aiohttp.ClientTimeout(total=timeout)
64
+ self._session: Optional[aiohttp.ClientSession] = None
65
+
66
+ async def __aenter__(self):
67
+ self._session = aiohttp.ClientSession(timeout=self.timeout)
68
+ return self
69
+
70
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
71
+ if self._session:
72
+ await self._session.close()
73
+
74
+ async def execute_request(
75
+ self,
76
+ method: str,
77
+ path: str,
78
+ headers: Optional[dict[str, str]] = None,
79
+ body: Optional[str] = None,
80
+ json_body: Optional[dict] = None,
81
+ follow_redirects: bool = False,
82
+ attempt: int = 1,
83
+ ) -> ExploitResult:
84
+ """Execute a single HTTP request against the sandbox."""
85
+ url = f"{self.base_url}{path}" if path.startswith("/") else path
86
+
87
+ if not self._session:
88
+ self._session = aiohttp.ClientSession(timeout=self.timeout)
89
+
90
+ start = time.time()
91
+ try:
92
+ kwargs: dict[str, Any] = {
93
+ "method": method.upper(),
94
+ "url": url,
95
+ "headers": headers or {},
96
+ "allow_redirects": follow_redirects,
97
+ }
98
+
99
+ if json_body is not None:
100
+ kwargs["json"] = json_body
101
+ elif body is not None:
102
+ kwargs["data"] = body
103
+
104
+ async with self._session.request(**kwargs) as resp:
105
+ elapsed = (time.time() - start) * 1000
106
+ resp_body = await resp.text()
107
+ resp_headers = dict(resp.headers)
108
+
109
+ return ExploitResult(
110
+ success=True,
111
+ status_code=resp.status,
112
+ headers=resp_headers,
113
+ body=resp_body,
114
+ elapsed_ms=elapsed,
115
+ attempt=attempt,
116
+ )
117
+
118
+ except asyncio.TimeoutError:
119
+ return ExploitResult(
120
+ success=False,
121
+ error=f"Request timed out after {self.timeout.total}s",
122
+ elapsed_ms=(time.time() - start) * 1000,
123
+ attempt=attempt,
124
+ )
125
+ except aiohttp.ClientError as e:
126
+ return ExploitResult(
127
+ success=False,
128
+ error=f"Connection error: {str(e)}",
129
+ elapsed_ms=(time.time() - start) * 1000,
130
+ attempt=attempt,
131
+ )
132
+ except Exception as e:
133
+ return ExploitResult(
134
+ success=False,
135
+ error=f"Unexpected error: {str(e)}",
136
+ elapsed_ms=(time.time() - start) * 1000,
137
+ attempt=attempt,
138
+ )
139
+
140
+ async def execute_multi_step(
141
+ self,
142
+ steps: list[dict],
143
+ ) -> list[ExploitResult]:
144
+ """Execute a sequence of requests (for multi-step exploits).
145
+
146
+ Each step is a dict with: method, path, headers, body/json_body.
147
+ Later steps can reference earlier responses via {step_N_body} placeholders.
148
+ """
149
+ results: list[ExploitResult] = []
150
+
151
+ for i, step in enumerate(steps):
152
+ # Substitute placeholders from previous results
153
+ step_str = json.dumps(step)
154
+ for j, prev in enumerate(results):
155
+ placeholder = f"{{step_{j}_body}}"
156
+ if placeholder in step_str:
157
+ escaped = json.dumps(prev.body)[1:-1] # Remove outer quotes
158
+ step_str = step_str.replace(placeholder, escaped)
159
+
160
+ step = json.loads(step_str)
161
+
162
+ result = await self.execute_request(
163
+ method=step.get("method", "GET"),
164
+ path=step.get("path", "/"),
165
+ headers=step.get("headers"),
166
+ body=step.get("body"),
167
+ json_body=step.get("json_body"),
168
+ follow_redirects=step.get("follow_redirects", False),
169
+ attempt=i + 1,
170
+ )
171
+ results.append(result)
172
+
173
+ # If a step fails at the connection level, stop the chain
174
+ if not result.success:
175
+ break
176
+
177
+ return results