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.
- openhack/__init__.py +2 -0
- openhack/__main__.py +225 -0
- openhack/agents/__init__.py +30 -0
- openhack/agents/base.py +230 -0
- openhack/agents/browser_verifier.py +679 -0
- openhack/agents/browser_verifier_swarm.py +256 -0
- openhack/agents/checkpoint.py +89 -0
- openhack/agents/context_manager.py +356 -0
- openhack/agents/coordinator.py +1105 -0
- openhack/agents/endpoint_analyst.py +307 -0
- openhack/agents/feature_hunter.py +93 -0
- openhack/agents/hunter.py +481 -0
- openhack/agents/hunter_swarm.py +385 -0
- openhack/agents/llm.py +334 -0
- openhack/agents/recon.py +19 -0
- openhack/agents/sandbox_verifier.py +396 -0
- openhack/agents/sandbox_verifier_swarm.py +250 -0
- openhack/agents/session.py +286 -0
- openhack/agents/validator.py +217 -0
- openhack/agents/validator_swarm.py +106 -0
- openhack/auth.py +175 -0
- openhack/browser/__init__.py +12 -0
- openhack/browser/runner.py +385 -0
- openhack/categories.py +130 -0
- openhack/config.py +201 -0
- openhack/deterministic_recon.py +464 -0
- openhack/entry_points.py +745 -0
- openhack/framework_classifier.py +515 -0
- openhack/framework_detection.py +269 -0
- openhack/headless_scan.py +179 -0
- openhack/prompts/__init__.py +108 -0
- openhack/prompts/browser_verifier.py +171 -0
- openhack/prompts/coordinator.py +31 -0
- openhack/prompts/django/__init__.py +32 -0
- openhack/prompts/django/auth_bypass.py +76 -0
- openhack/prompts/django/csrf.py +62 -0
- openhack/prompts/django/data_exposure.py +67 -0
- openhack/prompts/django/idor.py +74 -0
- openhack/prompts/django/injection.py +67 -0
- openhack/prompts/django/misconfiguration.py +70 -0
- openhack/prompts/django/ssrf.py +64 -0
- openhack/prompts/endpoint_analyst.py +122 -0
- openhack/prompts/express/__init__.py +29 -0
- openhack/prompts/express/auth_bypass.py +71 -0
- openhack/prompts/express/data_exposure.py +77 -0
- openhack/prompts/express/idor.py +69 -0
- openhack/prompts/express/injection.py +75 -0
- openhack/prompts/express/misconfiguration.py +72 -0
- openhack/prompts/express/ssrf.py +63 -0
- openhack/prompts/feature_hunter.py +140 -0
- openhack/prompts/flask/__init__.py +29 -0
- openhack/prompts/flask/auth_bypass.py +86 -0
- openhack/prompts/flask/data_exposure.py +78 -0
- openhack/prompts/flask/idor.py +83 -0
- openhack/prompts/flask/injection.py +77 -0
- openhack/prompts/flask/misconfiguration.py +73 -0
- openhack/prompts/flask/ssrf.py +65 -0
- openhack/prompts/hunter.py +362 -0
- openhack/prompts/hunter_continuation_loop.py +12 -0
- openhack/prompts/hunter_continuation_no_findings.py +19 -0
- openhack/prompts/hunter_continuation_no_progress.py +22 -0
- openhack/prompts/hunter_tool_instructions.py +55 -0
- openhack/prompts/nextjs/__init__.py +42 -0
- openhack/prompts/nextjs/auth_bypass.py +80 -0
- openhack/prompts/nextjs/csrf.py +71 -0
- openhack/prompts/nextjs/data_exposure.py +88 -0
- openhack/prompts/nextjs/idor.py +64 -0
- openhack/prompts/nextjs/injection.py +65 -0
- openhack/prompts/nextjs/middleware_bypass.py +75 -0
- openhack/prompts/nextjs/misconfiguration.py +92 -0
- openhack/prompts/nextjs/server_actions.py +97 -0
- openhack/prompts/nextjs/ssrf.py +66 -0
- openhack/prompts/nextjs/xss.py +69 -0
- openhack/prompts/pr_analysis_system.py +80 -0
- openhack/prompts/pr_analysis_user.py +11 -0
- openhack/prompts/project_context.py +89 -0
- openhack/prompts/recon.py +199 -0
- openhack/prompts/reporter.py +88 -0
- openhack/prompts/researchers.py +434 -0
- openhack/prompts/sandbox_verifier.py +128 -0
- openhack/prompts/supabase/__init__.py +39 -0
- openhack/prompts/supabase/auth_tokens.py +131 -0
- openhack/prompts/supabase/edge_functions.py +150 -0
- openhack/prompts/supabase/graphql.py +102 -0
- openhack/prompts/supabase/postgrest.py +99 -0
- openhack/prompts/supabase/realtime.py +93 -0
- openhack/prompts/supabase/rls.py +110 -0
- openhack/prompts/supabase/rpc_functions.py +127 -0
- openhack/prompts/supabase/storage.py +110 -0
- openhack/prompts/supabase/tenant_isolation.py +118 -0
- openhack/prompts/validator.py +319 -0
- openhack/prompts/validator_continuation_incomplete.py +12 -0
- openhack/prompts/validator_tool_instructions.py +29 -0
- openhack/quality.py +231 -0
- openhack/sandbox/__init__.py +12 -0
- openhack/sandbox/orchestrator.py +517 -0
- openhack/sandbox/runner.py +177 -0
- openhack/scan_session.py +245 -0
- openhack/setup.py +452 -0
- openhack/static_validator.py +612 -0
- openhack/tools/__init__.py +1 -0
- openhack/tools/ast_tools.py +307 -0
- openhack/tools/coverage.py +1078 -0
- openhack/tools/filesystem.py +404 -0
- openhack/tools/nextjs.py +258 -0
- openhack/tools/registry.py +52 -0
- openhack/tui.py +3450 -0
- openhack/updates.py +170 -0
- openhack-0.1.0.dist-info/METADATA +189 -0
- openhack-0.1.0.dist-info/RECORD +113 -0
- openhack-0.1.0.dist-info/WHEEL +4 -0
- openhack-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|