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,396 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sandbox verifier agent.
|
|
3
|
+
|
|
4
|
+
Takes a confirmed finding and iteratively develops a working exploit
|
|
5
|
+
by executing requests against a live sandboxed instance of the target app.
|
|
6
|
+
The agent adapts its approach based on responses, trying multiple strategies
|
|
7
|
+
before concluding whether a vulnerability is exploitable.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .base import BaseAgent
|
|
15
|
+
from .llm import Message, ToolResult
|
|
16
|
+
from ..sandbox.runner import ExploitRunner, ExploitResult
|
|
17
|
+
from openhack.prompts import SANDBOX_VERIFIER_PROMPT, SANDBOX_VERIFIER_TOOL_INSTRUCTIONS
|
|
18
|
+
from openhack.prompts import format_project_context
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── Tool definitions for the sandbox verifier ──────────────────────
|
|
24
|
+
|
|
25
|
+
SANDBOX_HTTP_REQUEST_TOOL = {
|
|
26
|
+
"name": "sandbox_http_request",
|
|
27
|
+
"description": (
|
|
28
|
+
"Execute an HTTP request against the live sandboxed application. "
|
|
29
|
+
"Use this to test exploit payloads. Returns full response including "
|
|
30
|
+
"status code, headers, and body."
|
|
31
|
+
),
|
|
32
|
+
"parameters": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"method": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
|
38
|
+
"description": "HTTP method",
|
|
39
|
+
},
|
|
40
|
+
"path": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"description": "URL path (e.g., /api/users/1). Will be prefixed with the sandbox base URL.",
|
|
43
|
+
},
|
|
44
|
+
"headers": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"description": "HTTP headers as key-value pairs",
|
|
47
|
+
"additionalProperties": {"type": "string"},
|
|
48
|
+
},
|
|
49
|
+
"body": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "Raw request body (for form data, XML, etc.)",
|
|
52
|
+
},
|
|
53
|
+
"json_body": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"description": "JSON request body (automatically sets Content-Type: application/json)",
|
|
56
|
+
},
|
|
57
|
+
"follow_redirects": {
|
|
58
|
+
"type": "boolean",
|
|
59
|
+
"description": "Whether to follow HTTP redirects (default: false)",
|
|
60
|
+
"default": False,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"required": ["method", "path"],
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
SANDBOX_MULTI_STEP_TOOL = {
|
|
68
|
+
"name": "sandbox_multi_step",
|
|
69
|
+
"description": (
|
|
70
|
+
"Execute a chain of HTTP requests for multi-step exploits. "
|
|
71
|
+
"Use this for exploits that need setup steps (e.g., register user → login → exploit). "
|
|
72
|
+
"Each step's response body is available to later steps via {step_N_body} placeholders."
|
|
73
|
+
),
|
|
74
|
+
"parameters": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"steps": {
|
|
78
|
+
"type": "array",
|
|
79
|
+
"items": {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"properties": {
|
|
82
|
+
"method": {"type": "string"},
|
|
83
|
+
"path": {"type": "string"},
|
|
84
|
+
"headers": {"type": "object", "additionalProperties": {"type": "string"}},
|
|
85
|
+
"body": {"type": "string"},
|
|
86
|
+
"json_body": {"type": "object"},
|
|
87
|
+
"follow_redirects": {"type": "boolean"},
|
|
88
|
+
},
|
|
89
|
+
"required": ["method", "path"],
|
|
90
|
+
},
|
|
91
|
+
"description": "Ordered list of HTTP requests to execute",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
"required": ["steps"],
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
SANDBOX_GET_LOGS_TOOL = {
|
|
99
|
+
"name": "sandbox_get_logs",
|
|
100
|
+
"description": (
|
|
101
|
+
"Get the Docker container logs from the sandboxed application. "
|
|
102
|
+
"Useful for debugging when requests fail unexpectedly or to see "
|
|
103
|
+
"server-side errors triggered by your exploit attempts."
|
|
104
|
+
),
|
|
105
|
+
"parameters": {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"properties": {
|
|
108
|
+
"tail": {
|
|
109
|
+
"type": "integer",
|
|
110
|
+
"description": "Number of log lines to return (default: 50)",
|
|
111
|
+
"default": 50,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
REPORT_EXPLOIT_RESULT_TOOL = {
|
|
118
|
+
"name": "report_exploit_result",
|
|
119
|
+
"description": (
|
|
120
|
+
"Report the final result of your exploit verification. "
|
|
121
|
+
"Call this when you have either confirmed the exploit works "
|
|
122
|
+
"or determined it is not exploitable after multiple attempts."
|
|
123
|
+
),
|
|
124
|
+
"parameters": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {
|
|
127
|
+
"status": {
|
|
128
|
+
"type": "string",
|
|
129
|
+
"enum": ["exploitable", "not_exploitable"],
|
|
130
|
+
"description": "Whether the vulnerability was successfully exploited",
|
|
131
|
+
},
|
|
132
|
+
"confidence": {
|
|
133
|
+
"type": "string",
|
|
134
|
+
"enum": ["high", "medium", "low"],
|
|
135
|
+
"description": "Confidence in the result",
|
|
136
|
+
},
|
|
137
|
+
"working_poc": {
|
|
138
|
+
"type": "string",
|
|
139
|
+
"description": "The working Python exploit script (for exploitable findings)",
|
|
140
|
+
},
|
|
141
|
+
"evidence": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"description": "Evidence: response data proving exploitation, or explanation of why it failed",
|
|
144
|
+
},
|
|
145
|
+
"attempts_made": {
|
|
146
|
+
"type": "integer",
|
|
147
|
+
"description": "How many exploit attempts were made",
|
|
148
|
+
},
|
|
149
|
+
"exploit_request": {
|
|
150
|
+
"type": "object",
|
|
151
|
+
"description": "The exact HTTP request that worked (method, path, headers, body)",
|
|
152
|
+
},
|
|
153
|
+
"reason": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"description": "For not_exploitable: why the exploit cannot work in practice",
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
"required": ["status", "confidence", "evidence", "attempts_made"],
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
SANDBOX_TOOLS = [
|
|
164
|
+
SANDBOX_HTTP_REQUEST_TOOL,
|
|
165
|
+
SANDBOX_MULTI_STEP_TOOL,
|
|
166
|
+
SANDBOX_GET_LOGS_TOOL,
|
|
167
|
+
REPORT_EXPLOIT_RESULT_TOOL,
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SandboxVerifierAgent(BaseAgent):
|
|
172
|
+
"""Agent that verifies vulnerabilities by exploiting them in a sandbox."""
|
|
173
|
+
|
|
174
|
+
name = "sandbox_verifier"
|
|
175
|
+
description = "Verifying exploit in sandbox"
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
*args,
|
|
180
|
+
sandbox_url: str = "",
|
|
181
|
+
exploit_runner: Optional[ExploitRunner] = None,
|
|
182
|
+
sandbox_orchestrator=None,
|
|
183
|
+
finding_index: int = 0,
|
|
184
|
+
max_attempts: int = 7,
|
|
185
|
+
**kwargs,
|
|
186
|
+
):
|
|
187
|
+
super().__init__(*args, **kwargs)
|
|
188
|
+
self.sandbox_url = sandbox_url
|
|
189
|
+
self.exploit_runner = exploit_runner
|
|
190
|
+
self.sandbox_orchestrator = sandbox_orchestrator
|
|
191
|
+
self.finding_index = finding_index
|
|
192
|
+
self.max_attempts = max_attempts
|
|
193
|
+
self.exploit_result: Optional[dict] = None
|
|
194
|
+
self.attempt_count = 0
|
|
195
|
+
|
|
196
|
+
self.name = f"sandbox_verifier:finding_{finding_index}"
|
|
197
|
+
self.description = f"Exploiting finding {finding_index} in sandbox"
|
|
198
|
+
|
|
199
|
+
def get_system_prompt(self, context: dict) -> str:
|
|
200
|
+
finding = context.get("finding", {})
|
|
201
|
+
project_context = context.get("project_context", {})
|
|
202
|
+
project_context_str = format_project_context(project_context)
|
|
203
|
+
|
|
204
|
+
finding_details = f"""
|
|
205
|
+
### Vulnerability: {finding.get('category', 'Unknown')}
|
|
206
|
+
- **Severity**: {finding.get('severity', 'Unknown')}
|
|
207
|
+
- **File**: {finding.get('file_path', 'Unknown')}
|
|
208
|
+
- **Line**: {finding.get('line_number', 'Unknown')}
|
|
209
|
+
- **Description**: {finding.get('description', 'No description')}
|
|
210
|
+
- **Code**:
|
|
211
|
+
```
|
|
212
|
+
{finding.get('code_snippet', 'No code snippet')}
|
|
213
|
+
```
|
|
214
|
+
- **Original PoC**:
|
|
215
|
+
```
|
|
216
|
+
{finding.get('poc', 'No PoC provided')}
|
|
217
|
+
```
|
|
218
|
+
- **Confidence**: {finding.get('confidence', 'Unknown')}
|
|
219
|
+
- **CVSS Score**: {finding.get('cvss_score', 'N/A')}
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
prompt = SANDBOX_VERIFIER_PROMPT.format(
|
|
223
|
+
project_context=project_context_str,
|
|
224
|
+
sandbox_url=self.sandbox_url,
|
|
225
|
+
finding_details=finding_details,
|
|
226
|
+
max_attempts=self.max_attempts,
|
|
227
|
+
)
|
|
228
|
+
prompt += SANDBOX_VERIFIER_TOOL_INSTRUCTIONS
|
|
229
|
+
return prompt
|
|
230
|
+
|
|
231
|
+
def get_tools(self) -> list[dict]:
|
|
232
|
+
# Include filesystem tools (read_file, grep) plus sandbox tools
|
|
233
|
+
return super().get_tools() + SANDBOX_TOOLS
|
|
234
|
+
|
|
235
|
+
async def _handle_sandbox_http_request(self, args: dict) -> dict:
|
|
236
|
+
"""Execute a single HTTP request against the sandbox."""
|
|
237
|
+
self.attempt_count += 1
|
|
238
|
+
|
|
239
|
+
result = await self.exploit_runner.execute_request(
|
|
240
|
+
method=args.get("method", "GET"),
|
|
241
|
+
path=args.get("path", "/"),
|
|
242
|
+
headers=args.get("headers"),
|
|
243
|
+
body=args.get("body"),
|
|
244
|
+
json_body=args.get("json_body"),
|
|
245
|
+
follow_redirects=args.get("follow_redirects", False),
|
|
246
|
+
attempt=self.attempt_count,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return result.to_dict()
|
|
250
|
+
|
|
251
|
+
async def _handle_sandbox_multi_step(self, args: dict) -> dict:
|
|
252
|
+
"""Execute a multi-step exploit chain."""
|
|
253
|
+
steps = args.get("steps", [])
|
|
254
|
+
if not steps:
|
|
255
|
+
return {"error": "No steps provided"}
|
|
256
|
+
|
|
257
|
+
self.attempt_count += 1
|
|
258
|
+
results = await self.exploit_runner.execute_multi_step(steps)
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
"steps_executed": len(results),
|
|
262
|
+
"results": [r.to_dict() for r in results],
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async def _handle_sandbox_get_logs(self, args: dict) -> dict:
|
|
266
|
+
"""Get container logs."""
|
|
267
|
+
if not self.sandbox_orchestrator:
|
|
268
|
+
return {"error": "No sandbox orchestrator available"}
|
|
269
|
+
|
|
270
|
+
tail = args.get("tail", 50)
|
|
271
|
+
logs = await self.sandbox_orchestrator.get_logs(tail=tail)
|
|
272
|
+
return {"logs": logs}
|
|
273
|
+
|
|
274
|
+
def _handle_report_exploit_result(self, args: dict) -> dict:
|
|
275
|
+
"""Record the final exploit result."""
|
|
276
|
+
self.exploit_result = {
|
|
277
|
+
"finding_index": self.finding_index,
|
|
278
|
+
"status": args.get("status", "not_exploitable"),
|
|
279
|
+
"confidence": args.get("confidence", "medium"),
|
|
280
|
+
"working_poc": args.get("working_poc"),
|
|
281
|
+
"evidence": args.get("evidence", ""),
|
|
282
|
+
"attempts_made": args.get("attempts_made", self.attempt_count),
|
|
283
|
+
"exploit_request": args.get("exploit_request"),
|
|
284
|
+
"reason": args.get("reason"),
|
|
285
|
+
}
|
|
286
|
+
return {"status": "recorded", "finding_index": self.finding_index}
|
|
287
|
+
|
|
288
|
+
async def run(self, task: str, context: Optional[dict] = None) -> dict:
|
|
289
|
+
context = context or {}
|
|
290
|
+
self.session.current_agent = self.name
|
|
291
|
+
self.exploit_result = None
|
|
292
|
+
self.attempt_count = 0
|
|
293
|
+
|
|
294
|
+
system_prompt = self.get_system_prompt(context)
|
|
295
|
+
self.messages = [Message(role="user", content=task)]
|
|
296
|
+
self._seed_existing_instructions()
|
|
297
|
+
|
|
298
|
+
max_iterations = self.max_attempts * 4 # Allow multiple tool calls per attempt
|
|
299
|
+
iteration = 0
|
|
300
|
+
|
|
301
|
+
while iteration < max_iterations:
|
|
302
|
+
if self.session.cancelled:
|
|
303
|
+
break
|
|
304
|
+
iteration += 1
|
|
305
|
+
|
|
306
|
+
self._inject_pending_instructions()
|
|
307
|
+
|
|
308
|
+
response = await self.llm.chat(
|
|
309
|
+
messages=self.messages, tools=self.get_tools(), system=system_prompt,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
self.session.total_cost += response.cost
|
|
313
|
+
if response.usage:
|
|
314
|
+
self.session.total_tokens += response.usage.get("total_tokens", 0)
|
|
315
|
+
self.context_manager.update_usage(response.usage.get("input_tokens", 0))
|
|
316
|
+
|
|
317
|
+
if response.content:
|
|
318
|
+
self.session.add_trace(
|
|
319
|
+
agent=self.name, event_type="thinking", content=response.content,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
if not response.tool_calls:
|
|
323
|
+
return self._build_result(response.content or "")
|
|
324
|
+
|
|
325
|
+
assistant_msg = Message(
|
|
326
|
+
role="assistant", content=response.content,
|
|
327
|
+
tool_calls=[
|
|
328
|
+
{"id": tc.id, "type": "function", "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)}}
|
|
329
|
+
for tc in response.tool_calls
|
|
330
|
+
],
|
|
331
|
+
reasoning_content=getattr(response, 'reasoning_content', None),
|
|
332
|
+
)
|
|
333
|
+
self.messages.append(assistant_msg)
|
|
334
|
+
|
|
335
|
+
should_finish = False
|
|
336
|
+
for tool_call in response.tool_calls:
|
|
337
|
+
self.session.add_trace(
|
|
338
|
+
agent=self.name, event_type="tool_call",
|
|
339
|
+
content=f"Calling {tool_call.name}",
|
|
340
|
+
tool_name=tool_call.name, tool_input=tool_call.arguments,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if tool_call.name == "sandbox_http_request":
|
|
344
|
+
result = await self._handle_sandbox_http_request(tool_call.arguments)
|
|
345
|
+
elif tool_call.name == "sandbox_multi_step":
|
|
346
|
+
result = await self._handle_sandbox_multi_step(tool_call.arguments)
|
|
347
|
+
elif tool_call.name == "sandbox_get_logs":
|
|
348
|
+
result = await self._handle_sandbox_get_logs(tool_call.arguments)
|
|
349
|
+
elif tool_call.name == "report_exploit_result":
|
|
350
|
+
result = self._handle_report_exploit_result(tool_call.arguments)
|
|
351
|
+
should_finish = True
|
|
352
|
+
else:
|
|
353
|
+
# Filesystem tools (read_file, grep, etc.)
|
|
354
|
+
result = self.tools.execute_tool(tool_call.name, tool_call.arguments)
|
|
355
|
+
|
|
356
|
+
self.session.add_trace(
|
|
357
|
+
agent=self.name, event_type="tool_result",
|
|
358
|
+
content=f"Result from {tool_call.name}",
|
|
359
|
+
tool_name=tool_call.name, tool_output=result,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
raw_content = json.dumps(result) if isinstance(result, dict) else str(result)
|
|
363
|
+
truncated_content = self.context_manager.truncate_tool_result(tool_call.name, raw_content)
|
|
364
|
+
tool_result = ToolResult(tool_call_id=tool_call.id, content=truncated_content)
|
|
365
|
+
self.messages.append(tool_result.to_message())
|
|
366
|
+
|
|
367
|
+
if should_finish:
|
|
368
|
+
return self._build_result(response.content or "")
|
|
369
|
+
|
|
370
|
+
if self.context_manager.needs_compaction():
|
|
371
|
+
self.messages = self.context_manager.compact_messages(self.messages)
|
|
372
|
+
logger.info(f"[{self.name}] Compacted message history")
|
|
373
|
+
|
|
374
|
+
# Max iterations reached without explicit report
|
|
375
|
+
if not self.exploit_result:
|
|
376
|
+
self.exploit_result = {
|
|
377
|
+
"finding_index": self.finding_index,
|
|
378
|
+
"status": "not_exploitable",
|
|
379
|
+
"confidence": "low",
|
|
380
|
+
"evidence": "Max iterations reached without confirming exploit",
|
|
381
|
+
"attempts_made": self.attempt_count,
|
|
382
|
+
"reason": "Agent exhausted iteration budget",
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return self._build_result("Max iterations reached")
|
|
386
|
+
|
|
387
|
+
def _build_result(self, summary: str) -> dict:
|
|
388
|
+
return {
|
|
389
|
+
"raw_output": summary,
|
|
390
|
+
"exploit_result": self.exploit_result,
|
|
391
|
+
"attempts_made": self.attempt_count,
|
|
392
|
+
"type": "sandbox_verification_complete",
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
def _parse_final_response(self, content: str) -> dict:
|
|
396
|
+
return self._build_result(content)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sandbox verifier swarm agent.
|
|
3
|
+
|
|
4
|
+
Spawns one sandbox verifier per confirmed finding and runs them concurrently
|
|
5
|
+
against the live sandboxed application. All verifiers share the same sandbox
|
|
6
|
+
instance — the app is started once and torn down after all verifiers finish.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .sandbox_verifier import SandboxVerifierAgent
|
|
14
|
+
from .llm import LLMClient
|
|
15
|
+
from .session import Session
|
|
16
|
+
from ..sandbox.orchestrator import SandboxOrchestrator, SandboxConfig
|
|
17
|
+
from ..sandbox.runner import ExploitRunner
|
|
18
|
+
from openhack.tools.registry import ToolRegistry
|
|
19
|
+
from openhack.config import settings
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SandboxVerifierSwarmAgent:
|
|
25
|
+
"""Runs sandbox verification for all confirmed findings concurrently."""
|
|
26
|
+
|
|
27
|
+
name = "sandbox_verifier_swarm"
|
|
28
|
+
description = "Sandbox exploit verification swarm"
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
llm: LLMClient,
|
|
33
|
+
tools: ToolRegistry,
|
|
34
|
+
session: Session,
|
|
35
|
+
sandbox_config: Optional[SandboxConfig] = None,
|
|
36
|
+
):
|
|
37
|
+
self.llm = llm
|
|
38
|
+
self.tools = tools
|
|
39
|
+
self.session = session
|
|
40
|
+
self.sandbox_config = sandbox_config
|
|
41
|
+
self.total_cost: float = 0.0
|
|
42
|
+
self.total_tokens: int = 0
|
|
43
|
+
self.total_input_tokens: int = 0
|
|
44
|
+
self.total_output_tokens: int = 0
|
|
45
|
+
|
|
46
|
+
def _create_llm_for_verifier(self) -> LLMClient:
|
|
47
|
+
model = self.llm.model
|
|
48
|
+
return LLMClient(model=model, temperature=0.0, max_tokens=8192, provider=self.llm.provider, prompt_cache_key=self.llm.prompt_cache_key)
|
|
49
|
+
|
|
50
|
+
async def run(self, task: str, context: Optional[dict] = None) -> dict:
|
|
51
|
+
context = context or {}
|
|
52
|
+
findings = context.get("confirmed_findings", [])
|
|
53
|
+
|
|
54
|
+
if not findings:
|
|
55
|
+
return {
|
|
56
|
+
"raw_output": "No findings to verify in sandbox",
|
|
57
|
+
"exploitable": [],
|
|
58
|
+
"not_exploitable": [],
|
|
59
|
+
"type": "sandbox_verification_complete",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
self.session.add_trace(
|
|
63
|
+
agent=self.name, event_type="swarm_start",
|
|
64
|
+
content={"findings_count": len(findings)},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
target_dir = self.tools.target_dir
|
|
68
|
+
orchestrator = SandboxOrchestrator(target_dir, self.sandbox_config)
|
|
69
|
+
|
|
70
|
+
self.session.add_trace(
|
|
71
|
+
agent=self.name, event_type="sandbox_starting",
|
|
72
|
+
content="Building and starting sandbox containers…",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
sandbox_status = await orchestrator.start()
|
|
77
|
+
sandbox_url = sandbox_status.base_url
|
|
78
|
+
|
|
79
|
+
self.session.add_trace(
|
|
80
|
+
agent=self.name, event_type="sandbox_ready",
|
|
81
|
+
content={"base_url": sandbox_url, "host_port": sandbox_status.host_port},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Create exploit runner
|
|
85
|
+
async with ExploitRunner(sandbox_url) as runner:
|
|
86
|
+
# Spawn verifiers
|
|
87
|
+
semaphore = asyncio.Semaphore(settings.max_concurrent_validators)
|
|
88
|
+
# Fail-fast: if N consecutive verifiers crash with the same
|
|
89
|
+
# error (e.g. "Insufficient credits"), abort the remaining
|
|
90
|
+
# ones rather than burning through all findings silently.
|
|
91
|
+
FAIL_FAST_THRESHOLD = 3
|
|
92
|
+
abort_event = asyncio.Event()
|
|
93
|
+
error_streak: list[str] = []
|
|
94
|
+
fatal_error: Optional[str] = None
|
|
95
|
+
|
|
96
|
+
async def run_verifier(idx: int, finding: dict) -> tuple[int, dict]:
|
|
97
|
+
nonlocal fatal_error
|
|
98
|
+
verifier_name = f"sandbox_verifier:finding_{idx}"
|
|
99
|
+
self.session.add_trace(
|
|
100
|
+
agent=verifier_name, event_type="queued",
|
|
101
|
+
content={"finding_index": idx, "title": finding.get("title", "")},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if abort_event.is_set():
|
|
105
|
+
self.session.add_trace(
|
|
106
|
+
agent=verifier_name, event_type="skipped",
|
|
107
|
+
content="Skipped — swarm aborted due to repeated failures",
|
|
108
|
+
)
|
|
109
|
+
llm = self._create_llm_for_verifier()
|
|
110
|
+
return idx, {
|
|
111
|
+
"exploit_result": {
|
|
112
|
+
"finding_index": idx, "status": "skipped",
|
|
113
|
+
"confidence": "none", "evidence": "Aborted",
|
|
114
|
+
"attempts_made": 0, "reason": fatal_error or "Aborted",
|
|
115
|
+
},
|
|
116
|
+
"type": "sandbox_verification_skipped",
|
|
117
|
+
}, llm
|
|
118
|
+
|
|
119
|
+
async with semaphore:
|
|
120
|
+
if abort_event.is_set():
|
|
121
|
+
self.session.add_trace(
|
|
122
|
+
agent=verifier_name, event_type="skipped",
|
|
123
|
+
content="Skipped — swarm aborted due to repeated failures",
|
|
124
|
+
)
|
|
125
|
+
llm = self._create_llm_for_verifier()
|
|
126
|
+
return idx, {
|
|
127
|
+
"exploit_result": {
|
|
128
|
+
"finding_index": idx, "status": "skipped",
|
|
129
|
+
"confidence": "none", "evidence": "Aborted",
|
|
130
|
+
"attempts_made": 0, "reason": fatal_error or "Aborted",
|
|
131
|
+
},
|
|
132
|
+
"type": "sandbox_verification_skipped",
|
|
133
|
+
}, llm
|
|
134
|
+
|
|
135
|
+
llm = self._create_llm_for_verifier()
|
|
136
|
+
verifier = SandboxVerifierAgent(
|
|
137
|
+
llm, self.tools, self.session,
|
|
138
|
+
sandbox_url=sandbox_url,
|
|
139
|
+
exploit_runner=runner,
|
|
140
|
+
sandbox_orchestrator=orchestrator,
|
|
141
|
+
finding_index=idx,
|
|
142
|
+
max_attempts=settings.sandbox_max_exploit_attempts,
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
sub_context = {
|
|
146
|
+
"finding": finding,
|
|
147
|
+
"project_context": context.get("project_context", {}),
|
|
148
|
+
}
|
|
149
|
+
result = await verifier.run(
|
|
150
|
+
"Verify this vulnerability by exploiting it in the sandbox.",
|
|
151
|
+
context=sub_context,
|
|
152
|
+
)
|
|
153
|
+
error_streak.clear()
|
|
154
|
+
return idx, result, llm
|
|
155
|
+
except Exception as e:
|
|
156
|
+
error_msg = str(e)
|
|
157
|
+
logger.error(f"Sandbox verifier for finding {idx} failed: {e}")
|
|
158
|
+
self.session.add_trace(
|
|
159
|
+
agent=verifier_name, event_type="error",
|
|
160
|
+
content=f"Verifier crashed: {e}",
|
|
161
|
+
)
|
|
162
|
+
error_streak.append(error_msg)
|
|
163
|
+
if (
|
|
164
|
+
len(error_streak) >= FAIL_FAST_THRESHOLD
|
|
165
|
+
and len(set(error_streak[-FAIL_FAST_THRESHOLD:])) == 1
|
|
166
|
+
):
|
|
167
|
+
fatal_error = error_msg
|
|
168
|
+
abort_event.set()
|
|
169
|
+
self.session.add_trace(
|
|
170
|
+
agent=self.name, event_type="swarm_aborted",
|
|
171
|
+
content=(
|
|
172
|
+
f"Aborting: {FAIL_FAST_THRESHOLD} consecutive "
|
|
173
|
+
f"verifiers failed with: {error_msg}"
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
return idx, {
|
|
177
|
+
"exploit_result": {
|
|
178
|
+
"finding_index": idx,
|
|
179
|
+
"status": "not_exploitable",
|
|
180
|
+
"confidence": "low",
|
|
181
|
+
"evidence": f"Verifier crashed: {error_msg}",
|
|
182
|
+
"attempts_made": 0,
|
|
183
|
+
"reason": "Internal error",
|
|
184
|
+
},
|
|
185
|
+
"type": "sandbox_verification_failed",
|
|
186
|
+
}, llm
|
|
187
|
+
|
|
188
|
+
tasks = [
|
|
189
|
+
asyncio.create_task(run_verifier(idx, finding))
|
|
190
|
+
for idx, finding in enumerate(findings)
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
results = await asyncio.gather(*tasks)
|
|
195
|
+
except asyncio.CancelledError:
|
|
196
|
+
for t in tasks:
|
|
197
|
+
t.cancel()
|
|
198
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
199
|
+
raise
|
|
200
|
+
|
|
201
|
+
# Collect results
|
|
202
|
+
exploitable = []
|
|
203
|
+
not_exploitable = []
|
|
204
|
+
|
|
205
|
+
for idx, result, llm_client in results:
|
|
206
|
+
self.total_cost += llm_client.total_cost
|
|
207
|
+
self.total_tokens += llm_client.total_tokens
|
|
208
|
+
self.total_input_tokens += llm_client.total_input_tokens
|
|
209
|
+
self.total_output_tokens += llm_client.total_output_tokens
|
|
210
|
+
|
|
211
|
+
exploit_result = result.get("exploit_result") if result else None
|
|
212
|
+
if not exploit_result:
|
|
213
|
+
not_exploitable.append({"finding_index": idx, "status": "error", "confidence": "low"})
|
|
214
|
+
continue
|
|
215
|
+
if exploit_result.get("status") == "exploitable":
|
|
216
|
+
exploitable.append(exploit_result)
|
|
217
|
+
else:
|
|
218
|
+
not_exploitable.append(exploit_result)
|
|
219
|
+
|
|
220
|
+
self.session.add_trace(
|
|
221
|
+
agent=self.name, event_type="swarm_complete",
|
|
222
|
+
content={
|
|
223
|
+
"total_exploitable": len(exploitable),
|
|
224
|
+
"total_not_exploitable": len(not_exploitable),
|
|
225
|
+
"total_cost": self.total_cost,
|
|
226
|
+
"total_tokens": self.total_tokens,
|
|
227
|
+
"fatal_error": fatal_error,
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
result_dict = {
|
|
232
|
+
"raw_output": (
|
|
233
|
+
f"Sandbox verification complete: {len(exploitable)} exploitable, "
|
|
234
|
+
f"{len(not_exploitable)} not exploitable out of {len(findings)} findings"
|
|
235
|
+
),
|
|
236
|
+
"exploitable": exploitable,
|
|
237
|
+
"not_exploitable": not_exploitable,
|
|
238
|
+
"type": "sandbox_verification_complete",
|
|
239
|
+
}
|
|
240
|
+
if fatal_error:
|
|
241
|
+
result_dict["fatal_error"] = fatal_error
|
|
242
|
+
return result_dict
|
|
243
|
+
|
|
244
|
+
finally:
|
|
245
|
+
# Always tear down the sandbox
|
|
246
|
+
self.session.add_trace(
|
|
247
|
+
agent=self.name, event_type="sandbox_teardown",
|
|
248
|
+
content="Stopping sandbox containers",
|
|
249
|
+
)
|
|
250
|
+
await orchestrator.stop()
|