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
openhack/agents/llm.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM client for OpenHack.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Callable, Optional
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
import openai
|
|
12
|
+
|
|
13
|
+
from openhack.config import settings
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Message:
|
|
20
|
+
role: str
|
|
21
|
+
content: Optional[str] = None
|
|
22
|
+
tool_calls: Optional[list[dict]] = None
|
|
23
|
+
tool_call_id: Optional[str] = None
|
|
24
|
+
name: Optional[str] = None
|
|
25
|
+
reasoning_content: Optional[str] = None
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict:
|
|
28
|
+
d = {"role": self.role}
|
|
29
|
+
if self.content is not None:
|
|
30
|
+
d["content"] = self.content
|
|
31
|
+
if self.tool_calls is not None:
|
|
32
|
+
d["tool_calls"] = self.tool_calls
|
|
33
|
+
if self.tool_call_id is not None:
|
|
34
|
+
d["tool_call_id"] = self.tool_call_id
|
|
35
|
+
if self.name is not None:
|
|
36
|
+
d["name"] = self.name
|
|
37
|
+
if self.reasoning_content is not None:
|
|
38
|
+
d["reasoning_content"] = self.reasoning_content
|
|
39
|
+
return d
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ToolCall:
|
|
44
|
+
id: str
|
|
45
|
+
name: str
|
|
46
|
+
arguments: dict
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ToolResult:
|
|
51
|
+
tool_call_id: str
|
|
52
|
+
content: str
|
|
53
|
+
|
|
54
|
+
def to_message(self) -> Message:
|
|
55
|
+
return Message(role="tool", content=self.content, tool_call_id=self.tool_call_id)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class LLMResponse:
|
|
60
|
+
content: Optional[str] = None
|
|
61
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
62
|
+
usage: Optional[dict] = None
|
|
63
|
+
cost: float = 0.0
|
|
64
|
+
reasoning_content: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LLMClient:
|
|
68
|
+
"""LLM client for OpenHack."""
|
|
69
|
+
|
|
70
|
+
PRICING = {
|
|
71
|
+
"kimi-k2.5": {"input": 0.50, "output": 2.80},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
model: Optional[str] = None,
|
|
77
|
+
temperature: float = 0.0,
|
|
78
|
+
max_tokens: int = 8192,
|
|
79
|
+
provider: Optional[str] = None,
|
|
80
|
+
prompt_cache_key: Optional[str] = None,
|
|
81
|
+
):
|
|
82
|
+
self.provider = provider or settings.llm_provider
|
|
83
|
+
self.model = model or settings.openhack_model_id
|
|
84
|
+
|
|
85
|
+
self.temperature = temperature
|
|
86
|
+
self.max_tokens = max_tokens
|
|
87
|
+
self.prompt_cache_key = prompt_cache_key
|
|
88
|
+
self.total_cost: float = 0.0
|
|
89
|
+
self.total_tokens: int = 0
|
|
90
|
+
self.total_input_tokens: int = 0
|
|
91
|
+
self.total_output_tokens: int = 0
|
|
92
|
+
|
|
93
|
+
self._init_client()
|
|
94
|
+
|
|
95
|
+
def _init_client(self):
|
|
96
|
+
if not settings.openhack_api_key:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"OPENHACK_API_KEY is required.\n"
|
|
99
|
+
f"Sign up at: {settings.openhack_app_url}/signup\n"
|
|
100
|
+
"Then run: openhack /setup"
|
|
101
|
+
)
|
|
102
|
+
self.client = openai.AsyncOpenAI(
|
|
103
|
+
api_key=settings.openhack_api_key,
|
|
104
|
+
base_url=settings.openhack_base_url,
|
|
105
|
+
timeout=settings.openhack_read_timeout,
|
|
106
|
+
max_retries=0,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _calculate_cost(self, input_tokens: int, output_tokens: int) -> float:
|
|
110
|
+
pricing = self.PRICING.get(self.model, {"input": 0.50, "output": 2.80})
|
|
111
|
+
return (input_tokens / 1_000_000) * pricing["input"] + (output_tokens / 1_000_000) * pricing["output"]
|
|
112
|
+
|
|
113
|
+
def _convert_tools_to_openai_format(self, tools: list[dict]) -> list[dict]:
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
"type": "function",
|
|
117
|
+
"function": {
|
|
118
|
+
"name": tool["name"],
|
|
119
|
+
"description": tool.get("description", ""),
|
|
120
|
+
"parameters": tool.get("parameters", {"type": "object", "properties": {}}),
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
for tool in tools
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
def _convert_messages_to_openai(self, messages: list[Message], system: Optional[str]) -> list[dict]:
|
|
127
|
+
openai_messages = []
|
|
128
|
+
if system:
|
|
129
|
+
openai_messages.append({"role": "system", "content": system})
|
|
130
|
+
|
|
131
|
+
for msg in messages:
|
|
132
|
+
if msg.role == "system":
|
|
133
|
+
openai_messages.append({"role": "system", "content": msg.content or ""})
|
|
134
|
+
elif msg.role == "tool":
|
|
135
|
+
openai_messages.append({
|
|
136
|
+
"role": "tool",
|
|
137
|
+
"tool_call_id": msg.tool_call_id,
|
|
138
|
+
"content": msg.content or "",
|
|
139
|
+
})
|
|
140
|
+
elif msg.role == "assistant" and msg.tool_calls:
|
|
141
|
+
openai_messages.append({
|
|
142
|
+
"role": "assistant",
|
|
143
|
+
"content": msg.content,
|
|
144
|
+
"tool_calls": msg.tool_calls,
|
|
145
|
+
})
|
|
146
|
+
else:
|
|
147
|
+
openai_messages.append({"role": msg.role, "content": msg.content or ""})
|
|
148
|
+
|
|
149
|
+
return openai_messages
|
|
150
|
+
|
|
151
|
+
async def chat(
|
|
152
|
+
self,
|
|
153
|
+
messages: list[Message],
|
|
154
|
+
tools: Optional[list[dict]] = None,
|
|
155
|
+
system: Optional[str] = None,
|
|
156
|
+
tool_choice: Optional[str] = None,
|
|
157
|
+
on_chunk: Optional[Callable] = None,
|
|
158
|
+
) -> LLMResponse:
|
|
159
|
+
return await self._chat(messages, tools, system, tool_choice=tool_choice, on_chunk=on_chunk)
|
|
160
|
+
|
|
161
|
+
async def _chat(
|
|
162
|
+
self,
|
|
163
|
+
messages: list[Message],
|
|
164
|
+
tools: Optional[list[dict]] = None,
|
|
165
|
+
system: Optional[str] = None,
|
|
166
|
+
tool_choice: Optional[str] = None,
|
|
167
|
+
on_chunk: Optional[Callable] = None,
|
|
168
|
+
) -> LLMResponse:
|
|
169
|
+
openai_messages = self._convert_messages_to_openai(messages, system)
|
|
170
|
+
|
|
171
|
+
kwargs: dict[str, Any] = {
|
|
172
|
+
"model": self.model,
|
|
173
|
+
"messages": openai_messages,
|
|
174
|
+
"max_tokens": self.max_tokens,
|
|
175
|
+
"temperature": self.temperature,
|
|
176
|
+
"stream": True,
|
|
177
|
+
"stream_options": {"include_usage": True},
|
|
178
|
+
}
|
|
179
|
+
if tools:
|
|
180
|
+
kwargs["tools"] = self._convert_tools_to_openai_format(tools)
|
|
181
|
+
kwargs["tool_choice"] = tool_choice or "auto"
|
|
182
|
+
if self.prompt_cache_key:
|
|
183
|
+
kwargs["prompt_cache_key"] = self.prompt_cache_key
|
|
184
|
+
|
|
185
|
+
max_retries = settings.openhack_max_retries
|
|
186
|
+
last_exception = None
|
|
187
|
+
|
|
188
|
+
for attempt in range(max_retries + 1):
|
|
189
|
+
stream = None
|
|
190
|
+
try:
|
|
191
|
+
if attempt > 0:
|
|
192
|
+
wait_time = 5 * (2 ** (attempt - 1))
|
|
193
|
+
print(f" Retrying API call (attempt {attempt + 1}/{max_retries + 1}) after {wait_time}s...")
|
|
194
|
+
await asyncio.sleep(wait_time)
|
|
195
|
+
|
|
196
|
+
stream = await self.client.chat.completions.create(**kwargs)
|
|
197
|
+
|
|
198
|
+
content_parts: list[str] = []
|
|
199
|
+
reasoning_parts: list[str] = []
|
|
200
|
+
tool_call_acc: dict[int, dict] = {}
|
|
201
|
+
input_tokens = 0
|
|
202
|
+
output_tokens = 0
|
|
203
|
+
|
|
204
|
+
async for chunk in stream:
|
|
205
|
+
if chunk.usage:
|
|
206
|
+
input_tokens = chunk.usage.prompt_tokens or 0
|
|
207
|
+
output_tokens = chunk.usage.completion_tokens or 0
|
|
208
|
+
|
|
209
|
+
if not chunk.choices:
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
delta = chunk.choices[0].delta
|
|
213
|
+
|
|
214
|
+
if delta.content:
|
|
215
|
+
content_parts.append(delta.content)
|
|
216
|
+
if on_chunk:
|
|
217
|
+
on_chunk("content", delta.content)
|
|
218
|
+
|
|
219
|
+
rc = getattr(delta, "reasoning_content", None)
|
|
220
|
+
if rc:
|
|
221
|
+
reasoning_parts.append(rc)
|
|
222
|
+
if on_chunk:
|
|
223
|
+
on_chunk("reasoning", rc)
|
|
224
|
+
|
|
225
|
+
if delta.tool_calls:
|
|
226
|
+
for tc_delta in delta.tool_calls:
|
|
227
|
+
idx = tc_delta.index
|
|
228
|
+
if idx not in tool_call_acc:
|
|
229
|
+
tool_call_acc[idx] = {
|
|
230
|
+
"id": tc_delta.id or "",
|
|
231
|
+
"name": (tc_delta.function.name if tc_delta.function else "") or "",
|
|
232
|
+
"arguments_parts": [],
|
|
233
|
+
}
|
|
234
|
+
acc = tool_call_acc[idx]
|
|
235
|
+
if tc_delta.id:
|
|
236
|
+
acc["id"] = tc_delta.id
|
|
237
|
+
if tc_delta.function:
|
|
238
|
+
if tc_delta.function.name:
|
|
239
|
+
acc["name"] = tc_delta.function.name
|
|
240
|
+
if tc_delta.function.arguments:
|
|
241
|
+
acc["arguments_parts"].append(tc_delta.function.arguments)
|
|
242
|
+
|
|
243
|
+
content = "".join(content_parts) or None
|
|
244
|
+
reasoning_content = "".join(reasoning_parts) or None
|
|
245
|
+
|
|
246
|
+
tool_calls = []
|
|
247
|
+
for idx in sorted(tool_call_acc.keys()):
|
|
248
|
+
acc = tool_call_acc[idx]
|
|
249
|
+
raw_args = "".join(acc["arguments_parts"])
|
|
250
|
+
try:
|
|
251
|
+
args = json.loads(raw_args) if raw_args else {}
|
|
252
|
+
except json.JSONDecodeError:
|
|
253
|
+
logger.warning(f"Failed to parse tool call arguments: {raw_args[:200]}")
|
|
254
|
+
args = {}
|
|
255
|
+
tool_calls.append(ToolCall(id=acc["id"], name=acc["name"], arguments=args))
|
|
256
|
+
|
|
257
|
+
if input_tokens == 0 and output_tokens == 0:
|
|
258
|
+
logger.debug("No usage data in stream — cost will be zero for this call")
|
|
259
|
+
|
|
260
|
+
cost = self._calculate_cost(input_tokens, output_tokens)
|
|
261
|
+
self.total_cost += cost
|
|
262
|
+
self.total_tokens += input_tokens + output_tokens
|
|
263
|
+
self.total_input_tokens += input_tokens
|
|
264
|
+
self.total_output_tokens += output_tokens
|
|
265
|
+
|
|
266
|
+
llm_response = LLMResponse(
|
|
267
|
+
content=content,
|
|
268
|
+
tool_calls=tool_calls,
|
|
269
|
+
usage={"input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": input_tokens + output_tokens},
|
|
270
|
+
cost=cost,
|
|
271
|
+
)
|
|
272
|
+
llm_response.reasoning_content = reasoning_content
|
|
273
|
+
return llm_response
|
|
274
|
+
|
|
275
|
+
except openai.RateLimitError as e:
|
|
276
|
+
last_exception = e
|
|
277
|
+
if stream:
|
|
278
|
+
try: await stream.close()
|
|
279
|
+
except Exception: pass
|
|
280
|
+
if attempt == max_retries:
|
|
281
|
+
raise
|
|
282
|
+
except openai.AuthenticationError as e:
|
|
283
|
+
detail = getattr(e, "message", str(e))
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"Authentication failed (401): {detail}\n"
|
|
286
|
+
f"If this is your OpenHack token, run: openhack /login\n"
|
|
287
|
+
f"Check that your API key is valid and has not expired."
|
|
288
|
+
) from e
|
|
289
|
+
except openai.PermissionDeniedError as e:
|
|
290
|
+
detail = getattr(e, "message", str(e))
|
|
291
|
+
if "credits" in detail.lower() or "insufficient" in detail.lower():
|
|
292
|
+
raise ValueError(
|
|
293
|
+
f"Insufficient credits. Purchase more at: {settings.openhack_app_url}/settings/billing"
|
|
294
|
+
) from e
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"Access denied by OpenHack API: {detail}\n"
|
|
297
|
+
f"Check your API key at: {settings.openhack_app_url}/settings/api-keys"
|
|
298
|
+
) from e
|
|
299
|
+
except openai.APIStatusError as e:
|
|
300
|
+
if stream:
|
|
301
|
+
try: await stream.close()
|
|
302
|
+
except Exception: pass
|
|
303
|
+
if e.status_code >= 500:
|
|
304
|
+
last_exception = e
|
|
305
|
+
if attempt == max_retries:
|
|
306
|
+
raise
|
|
307
|
+
else:
|
|
308
|
+
raise
|
|
309
|
+
except openai.APITimeoutError as e:
|
|
310
|
+
last_exception = e
|
|
311
|
+
if stream:
|
|
312
|
+
try: await stream.close()
|
|
313
|
+
except Exception: pass
|
|
314
|
+
if attempt == max_retries:
|
|
315
|
+
raise
|
|
316
|
+
except openai.APIConnectionError as e:
|
|
317
|
+
last_exception = e
|
|
318
|
+
if stream:
|
|
319
|
+
try: await stream.close()
|
|
320
|
+
except Exception: pass
|
|
321
|
+
if attempt == max_retries:
|
|
322
|
+
raise
|
|
323
|
+
wait_time = 10 * (2 ** attempt)
|
|
324
|
+
await asyncio.sleep(wait_time)
|
|
325
|
+
continue
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.debug(f"OpenHack API error: {e}", exc_info=True)
|
|
328
|
+
if stream:
|
|
329
|
+
try: await stream.close()
|
|
330
|
+
except Exception: pass
|
|
331
|
+
raise
|
|
332
|
+
|
|
333
|
+
if last_exception:
|
|
334
|
+
raise last_exception
|
openhack/agents/recon.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reconnaissance agent for mapping application structure.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base import BaseAgent
|
|
6
|
+
from openhack.prompts import RECON_PROMPT, format_project_context
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ReconAgent(BaseAgent):
|
|
10
|
+
name = "recon"
|
|
11
|
+
description = "Reconnaissance - mapping application structure"
|
|
12
|
+
|
|
13
|
+
def get_system_prompt(self, context: dict) -> str:
|
|
14
|
+
project_context = context.get("project_context", {})
|
|
15
|
+
project_context_str = format_project_context(project_context)
|
|
16
|
+
return RECON_PROMPT.format(project_context=project_context_str)
|
|
17
|
+
|
|
18
|
+
def _parse_final_response(self, content: str) -> dict:
|
|
19
|
+
return {"summary": content, "type": "recon_complete"}
|