shadowcat 2.0.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.
- agent/__init__.py +17 -0
- agent/benchmark/__init__.py +11 -0
- agent/benchmark/cli.py +179 -0
- agent/benchmark/config.py +15 -0
- agent/benchmark/docker.py +192 -0
- agent/benchmark/registry.py +99 -0
- agent/core/__init__.py +0 -0
- agent/core/agent.py +362 -0
- agent/core/backend.py +1667 -0
- agent/core/config.py +106 -0
- agent/core/controller.py +638 -0
- agent/core/events.py +177 -0
- agent/core/langfuse.py +320 -0
- agent/core/phantom.py +2327 -0
- agent/core/planner.py +493 -0
- agent/core/profiling.py +58 -0
- agent/core/sanitizer.py +104 -0
- agent/core/session.py +228 -0
- agent/core/tracer.py +137 -0
- agent/interface/__init__.py +0 -0
- agent/interface/components/__init__.py +0 -0
- agent/interface/components/activity_feed.py +202 -0
- agent/interface/components/renderers.py +149 -0
- agent/interface/components/splash.py +112 -0
- agent/interface/main.py +1126 -0
- agent/interface/styles.tcss +421 -0
- agent/interface/tui.py +508 -0
- agent/parsing/html_distiller.py +230 -0
- agent/parsing/tool_parser.py +115 -0
- agent/prompts/__init__.py +0 -0
- agent/prompts/pentesting.py +238 -0
- agent/rag_module/knowledge_base.py +116 -0
- agent/tests/benchmark_phantom.py +455 -0
- agent/tools/__init__.py +14 -0
- agent/tools/base.py +99 -0
- agent/tools/executor.py +345 -0
- agent/tools/registry.py +47 -0
- backend/README.md +244 -0
- backend/__init__.py +10 -0
- backend/api/__init__.py +1 -0
- backend/api/routes_scan.py +932 -0
- backend/authz.py +75 -0
- backend/cli.py +261 -0
- backend/compliance/__init__.py +1 -0
- backend/compliance/pdpa_mapping.py +16 -0
- backend/core/__init__.py +1 -0
- backend/core/coverage.py +93 -0
- backend/core/events.py +166 -0
- backend/core/llm_client.py +614 -0
- backend/core/orchestrator.py +402 -0
- backend/core/scan_memory.py +236 -0
- backend/crawler/__init__.py +1 -0
- backend/crawler/csrf.py +75 -0
- backend/crawler/headless.py +232 -0
- backend/crawler/js_analyzer.py +133 -0
- backend/crawler/spider.py +550 -0
- backend/crawler/subdomains.py +279 -0
- backend/daemon.py +252 -0
- backend/db/README.md +86 -0
- backend/db/__init__.py +17 -0
- backend/db/engine.py +87 -0
- backend/db/models.py +206 -0
- backend/db/repository.py +316 -0
- backend/db/schema.sql +247 -0
- backend/modes/__init__.py +5 -0
- backend/modes/base.py +178 -0
- backend/modes/ctf.py +39 -0
- backend/modes/enterprise.py +210 -0
- backend/modes/general.py +226 -0
- backend/modes/registry.py +34 -0
- backend/reporting/__init__.py +0 -0
- backend/reporting/generator.py +285 -0
- backend/schemas/__init__.py +1 -0
- backend/schemas/api.py +423 -0
- backend/tools/__init__.py +62 -0
- backend/tools/dirbrute_tool.py +304 -0
- backend/tools/general_report_tool.py +135 -0
- backend/tools/http_tool.py +351 -0
- backend/tools/nuclei_tool.py +20 -0
- backend/tools/report_tool.py +145 -0
- backend/tools/shell_tools.py +23 -0
- backend/verification/__init__.py +1 -0
- backend/verification/evidence_store.py +125 -0
- backend/verification/general_oracle.py +369 -0
- backend/verification/idor_oracle.py +131 -0
- backend/waf/__init__.py +0 -0
- backend/waf/detector.py +147 -0
- backend/waf/evasion.py +117 -0
- backend/webui/index.html +713 -0
- backend/workspace.py +182 -0
- shadowcat-2.0.0.dist-info/METADATA +360 -0
- shadowcat-2.0.0.dist-info/RECORD +95 -0
- shadowcat-2.0.0.dist-info/WHEEL +4 -0
- shadowcat-2.0.0.dist-info/entry_points.txt +4 -0
- shadowcat-2.0.0.dist-info/licenses/LICENSE.md +21 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""HTTP request tool — the Enterprise-mode workhorse.
|
|
2
|
+
|
|
3
|
+
This is the *only* way the agent touches the network in Enterprise mode, and it
|
|
4
|
+
enforces two of the engine's hard guarantees:
|
|
5
|
+
|
|
6
|
+
1. The agent NEVER sees or handles raw credentials. To act as a user the
|
|
7
|
+
agent sets ``identity`` (e.g. ``"userA"``, ``"userB"``, ``"anon"``); the
|
|
8
|
+
system looks up that identity's secret headers/cookies and injects them.
|
|
9
|
+
Any credential header the model tries to set itself is stripped. Injected
|
|
10
|
+
credentials are never echoed back in the result.
|
|
11
|
+
|
|
12
|
+
2. The agent NEVER holds raw evidence. The full request/response exchange —
|
|
13
|
+
including the injected credentials — is written to the :class:`EvidenceStore`
|
|
14
|
+
server-side; the model gets back only a ``request_id`` and a compact,
|
|
15
|
+
credential-free summary. It must refer to the exchange by ``request_id``.
|
|
16
|
+
|
|
17
|
+
The tool itself is deliberately mechanical: it does what it is told and records
|
|
18
|
+
what happened. Scope enforcement (which host/range is authorized) is applied at
|
|
19
|
+
the orchestrator's shared execution boundary via ``SafetyPolicy`` — not here.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import random
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
from backend.verification.evidence_store import EvidenceStore
|
|
33
|
+
|
|
34
|
+
# Credential-bearing headers the model is forbidden from setting. Credentials
|
|
35
|
+
# are injected by the system per identity; if the model supplies any of these
|
|
36
|
+
# they are dropped before the request is built.
|
|
37
|
+
_CREDENTIAL_HEADERS = frozenset({"authorization", "cookie", "proxy-authorization"})
|
|
38
|
+
|
|
39
|
+
# Response headers worth surfacing to the model. Deliberately excludes
|
|
40
|
+
# ``set-cookie`` — a server-issued session token is a credential the model must
|
|
41
|
+
# not handle. The full header set (set-cookie included) lives in evidence.
|
|
42
|
+
_SUMMARY_RESPONSE_HEADERS = frozenset(
|
|
43
|
+
{
|
|
44
|
+
"content-type",
|
|
45
|
+
"content-length",
|
|
46
|
+
"location",
|
|
47
|
+
"www-authenticate",
|
|
48
|
+
"content-disposition",
|
|
49
|
+
"server",
|
|
50
|
+
"x-powered-by",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Browser-like default headers. Makes the scanner look like a real browser and
|
|
55
|
+
# bypasses User-Agent / Accept-header-based WAF blocklists. The model's supplied
|
|
56
|
+
# headers and identity headers always override these defaults.
|
|
57
|
+
_BROWSER_DEFAULTS: dict[str, str] = {
|
|
58
|
+
"User-Agent": (
|
|
59
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
60
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
61
|
+
"Chrome/124.0.0.0 Safari/537.36"
|
|
62
|
+
),
|
|
63
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
64
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
65
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# The model reasons over a bounded body preview; the full body is in evidence.
|
|
69
|
+
# Kept deliberately tight: this preview is re-sent in the conversation history on
|
|
70
|
+
# every subsequent turn, so on a non-cached model its size is paid O(turns²) over
|
|
71
|
+
# a long ReAct loop. ~900 chars still surfaces error signatures, reflected input,
|
|
72
|
+
# JSON shape, and the head of any data dump — enough to form a hypothesis and cite
|
|
73
|
+
# the request_id; the canonical full body lives in the evidence store.
|
|
74
|
+
_SUMMARY_BODY_MAX = 900
|
|
75
|
+
|
|
76
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Identity:
|
|
81
|
+
"""A seeded actor. ``headers`` and ``cookies`` are secrets the model never sees."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
85
|
+
cookies: dict[str, str] = field(default_factory=dict)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class IdentityStore:
|
|
89
|
+
"""Maps an identity name to its secret session material.
|
|
90
|
+
|
|
91
|
+
Seeded out-of-band by the run configuration. ``anon`` (no credentials)
|
|
92
|
+
always exists so the agent can gather unauthenticated negative controls.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, identities: list[Identity] | None = None) -> None:
|
|
96
|
+
self._identities: dict[str, Identity] = {}
|
|
97
|
+
self.register(Identity("anon"))
|
|
98
|
+
for identity in identities or []:
|
|
99
|
+
self.register(identity)
|
|
100
|
+
|
|
101
|
+
def register(self, identity: Identity) -> None:
|
|
102
|
+
self._identities[identity.name] = identity
|
|
103
|
+
|
|
104
|
+
def resolve(self, name: str) -> Identity | None:
|
|
105
|
+
return self._identities.get(name)
|
|
106
|
+
|
|
107
|
+
def names(self) -> list[str]:
|
|
108
|
+
return sorted(self._identities)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class HttpRequestTool:
|
|
112
|
+
"""Send an HTTP request as a seeded identity and capture it as evidence."""
|
|
113
|
+
|
|
114
|
+
name = "http_request"
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
evidence_store: EvidenceStore,
|
|
119
|
+
identities: IdentityStore,
|
|
120
|
+
*,
|
|
121
|
+
client: httpx.AsyncClient | None = None,
|
|
122
|
+
default_timeout: float = _DEFAULT_TIMEOUT,
|
|
123
|
+
default_follow_redirects: bool = False,
|
|
124
|
+
rate_limit_ms: int = 0,
|
|
125
|
+
) -> None:
|
|
126
|
+
self._evidence = evidence_store
|
|
127
|
+
self._identities = identities
|
|
128
|
+
self._client = client
|
|
129
|
+
self._owns_client = client is None
|
|
130
|
+
self._default_timeout = default_timeout
|
|
131
|
+
self._default_follow_redirects = default_follow_redirects
|
|
132
|
+
self._rate_limit_ms = rate_limit_ms
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Tool advertisement
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def schema(self) -> dict[str, Any]:
|
|
139
|
+
"""OpenAI-style function schema advertised to the model.
|
|
140
|
+
|
|
141
|
+
Note there is no header parameter for credentials: the model picks an
|
|
142
|
+
``identity`` and the system injects the session. ``rationale`` is
|
|
143
|
+
required so every action carries its (non-evidentiary) reasoning.
|
|
144
|
+
"""
|
|
145
|
+
return {
|
|
146
|
+
"type": "function",
|
|
147
|
+
"function": {
|
|
148
|
+
"name": self.name,
|
|
149
|
+
"description": (
|
|
150
|
+
"Send a single HTTP request to the authorized target through a "
|
|
151
|
+
"managed session and record the full exchange as evidence. To "
|
|
152
|
+
"act as a user, set `identity` (e.g. 'userA', 'userB', 'anon'); "
|
|
153
|
+
"the system injects that identity's session — you never handle "
|
|
154
|
+
"credentials. Returns a `request_id` plus a compact summary; "
|
|
155
|
+
"refer to the exchange ONLY by its request_id."
|
|
156
|
+
),
|
|
157
|
+
"parameters": {
|
|
158
|
+
"type": "object",
|
|
159
|
+
"properties": {
|
|
160
|
+
"rationale": {
|
|
161
|
+
"type": "string",
|
|
162
|
+
"description": "Concise, factual reason for this request "
|
|
163
|
+
"(hypothesis / step). Shown to operators; NOT evidence.",
|
|
164
|
+
},
|
|
165
|
+
"method": {
|
|
166
|
+
"type": "string",
|
|
167
|
+
"enum": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
168
|
+
},
|
|
169
|
+
"url": {"type": "string", "description": "Absolute URL within scope."},
|
|
170
|
+
"identity": {
|
|
171
|
+
"type": "string",
|
|
172
|
+
"description": "Seeded identity whose session to use "
|
|
173
|
+
"(e.g. 'userA', 'userB', 'anon'). Defaults to 'anon'.",
|
|
174
|
+
},
|
|
175
|
+
"headers": {
|
|
176
|
+
"type": "object",
|
|
177
|
+
"additionalProperties": {"type": "string"},
|
|
178
|
+
"description": "Extra non-credential request headers. "
|
|
179
|
+
"Authorization/Cookie are managed by `identity` and "
|
|
180
|
+
"will be ignored if set here.",
|
|
181
|
+
},
|
|
182
|
+
"body": {
|
|
183
|
+
"type": "string",
|
|
184
|
+
"description": "Raw request body (for POST/PUT/PATCH).",
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
"required": ["rationale", "method", "url", "identity"],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
# Execution
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def _ensure_client(self) -> httpx.AsyncClient:
|
|
197
|
+
if self._client is None:
|
|
198
|
+
# verify=False mirrors the existing pentest HTTP runtime: targets in
|
|
199
|
+
# an engagement frequently use self-signed certs.
|
|
200
|
+
self._client = httpx.AsyncClient(verify=False, timeout=self._default_timeout)
|
|
201
|
+
self._owns_client = True
|
|
202
|
+
return self._client
|
|
203
|
+
|
|
204
|
+
async def run(self, **kwargs: Any) -> dict[str, Any]:
|
|
205
|
+
"""Execute one request and return ONLY a request_id + credential-free summary."""
|
|
206
|
+
method = str(kwargs.get("method") or "GET").upper()
|
|
207
|
+
url = kwargs.get("url") or ""
|
|
208
|
+
identity_name = kwargs.get("identity") or "anon"
|
|
209
|
+
body = kwargs.get("body")
|
|
210
|
+
# Redirects are NOT auto-followed at the model's request: a 3xx Location
|
|
211
|
+
# can point outside the authorized scope, and the scope guard only checks
|
|
212
|
+
# the initial URL — auto-following would be an SSRF bypass (e.g. a target
|
|
213
|
+
# 302 -> http://169.254.169.254/...). The Location is surfaced in the
|
|
214
|
+
# summary; to pursue it the agent issues an explicit follow-up request,
|
|
215
|
+
# which IS scope-checked at the orchestrator boundary.
|
|
216
|
+
follow_redirects = self._default_follow_redirects
|
|
217
|
+
llm_headers: dict[str, str] = dict(kwargs.get("headers") or {})
|
|
218
|
+
|
|
219
|
+
if not url:
|
|
220
|
+
return {"error": "missing_parameter", "detail": "url is required"}
|
|
221
|
+
|
|
222
|
+
# Politeness delay with ±20% jitter — reduces synchronized bursts
|
|
223
|
+
# across concurrent workers and looks more like organic traffic to WAFs.
|
|
224
|
+
if self._rate_limit_ms > 0:
|
|
225
|
+
jitter = random.uniform(0.8, 1.2)
|
|
226
|
+
await asyncio.sleep(self._rate_limit_ms / 1000 * jitter)
|
|
227
|
+
|
|
228
|
+
identity = self._identities.resolve(identity_name)
|
|
229
|
+
if identity is None:
|
|
230
|
+
# Never reveal credentials; just say which identities exist.
|
|
231
|
+
return {
|
|
232
|
+
"error": "unknown_identity",
|
|
233
|
+
"identity": identity_name,
|
|
234
|
+
"known_identities": self._identities.names(),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Strip any credential headers the model tried to set, then build the
|
|
238
|
+
# final header set: browser defaults (lowest priority) → model-supplied
|
|
239
|
+
# non-credential headers → identity's secret headers (highest priority,
|
|
240
|
+
# so the managed session can never be overridden).
|
|
241
|
+
safe_headers = {
|
|
242
|
+
k: v for k, v in llm_headers.items() if k.lower() not in _CREDENTIAL_HEADERS
|
|
243
|
+
}
|
|
244
|
+
sent_headers = {**_BROWSER_DEFAULTS, **safe_headers, **identity.headers}
|
|
245
|
+
# Inject the identity's cookies as an explicit Cookie header rather than
|
|
246
|
+
# via the per-request ``cookies=`` kwarg (deprecated, and ambiguous about
|
|
247
|
+
# jar persistence). Building the header keeps each request's session
|
|
248
|
+
# deterministic — one identity's cookies can't bleed into another's call.
|
|
249
|
+
# TODO: for production, give each identity its own AsyncClient so the
|
|
250
|
+
# response cookie jar is isolated per identity too.
|
|
251
|
+
if identity.cookies:
|
|
252
|
+
sent_headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in identity.cookies.items())
|
|
253
|
+
|
|
254
|
+
# Server-side request record (includes injected credentials — never
|
|
255
|
+
# returned to the model, only persisted as evidence).
|
|
256
|
+
request_record: dict[str, Any] = {
|
|
257
|
+
"method": method,
|
|
258
|
+
"url": url,
|
|
259
|
+
"identity": identity_name,
|
|
260
|
+
"headers": sent_headers,
|
|
261
|
+
"cookies": dict(identity.cookies),
|
|
262
|
+
"body": body,
|
|
263
|
+
"follow_redirects": follow_redirects,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
client = self._ensure_client()
|
|
267
|
+
t0 = time.perf_counter()
|
|
268
|
+
try:
|
|
269
|
+
resp = await client.request(
|
|
270
|
+
method,
|
|
271
|
+
url,
|
|
272
|
+
headers=sent_headers,
|
|
273
|
+
content=body.encode() if isinstance(body, str) else body,
|
|
274
|
+
follow_redirects=follow_redirects,
|
|
275
|
+
)
|
|
276
|
+
except httpx.RequestError as exc:
|
|
277
|
+
# A transport failure is still evidence (DNS, timeout, refused).
|
|
278
|
+
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
|
279
|
+
response_record = {
|
|
280
|
+
"error": exc.__class__.__name__,
|
|
281
|
+
"detail": str(exc),
|
|
282
|
+
"elapsed_ms": elapsed_ms,
|
|
283
|
+
}
|
|
284
|
+
request_id = self._evidence.put(request_record, response_record)
|
|
285
|
+
return {
|
|
286
|
+
"request_id": request_id,
|
|
287
|
+
"identity": identity_name,
|
|
288
|
+
"request": {"method": method, "url": url},
|
|
289
|
+
"error": exc.__class__.__name__,
|
|
290
|
+
"detail": str(exc),
|
|
291
|
+
"elapsed_ms": elapsed_ms,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
|
295
|
+
body_text = resp.text
|
|
296
|
+
full_len = len(resp.content)
|
|
297
|
+
|
|
298
|
+
# Full response record (server-side evidence — every header, full body).
|
|
299
|
+
response_record = {
|
|
300
|
+
"status": resp.status_code,
|
|
301
|
+
"headers": dict(resp.headers),
|
|
302
|
+
"body": body_text,
|
|
303
|
+
"body_length": full_len,
|
|
304
|
+
"elapsed_ms": elapsed_ms,
|
|
305
|
+
"redirect_chain": [str(r.url) for r in resp.history] or None,
|
|
306
|
+
}
|
|
307
|
+
request_id = self._evidence.put(request_record, response_record)
|
|
308
|
+
|
|
309
|
+
return self._summary(
|
|
310
|
+
request_id, method, url, identity_name, resp, body_text, full_len, elapsed_ms
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def _summary(
|
|
314
|
+
self,
|
|
315
|
+
request_id: str,
|
|
316
|
+
method: str,
|
|
317
|
+
url: str,
|
|
318
|
+
identity_name: str,
|
|
319
|
+
resp: httpx.Response,
|
|
320
|
+
body_text: str,
|
|
321
|
+
full_len: int,
|
|
322
|
+
elapsed_ms: int,
|
|
323
|
+
) -> dict[str, Any]:
|
|
324
|
+
"""Compact, credential-free view for the model.
|
|
325
|
+
|
|
326
|
+
Echoes only ``method`` + ``url`` from the request (never the injected
|
|
327
|
+
headers/cookies) and a bounded body preview. The model reasons over
|
|
328
|
+
this; the canonical evidence is in the store under ``request_id``.
|
|
329
|
+
"""
|
|
330
|
+
preview = body_text[:_SUMMARY_BODY_MAX]
|
|
331
|
+
summary_headers = {
|
|
332
|
+
k: v for k, v in resp.headers.items() if k.lower() in _SUMMARY_RESPONSE_HEADERS
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
"request_id": request_id,
|
|
336
|
+
"identity": identity_name,
|
|
337
|
+
"request": {"method": method, "url": url},
|
|
338
|
+
"status": resp.status_code,
|
|
339
|
+
"response_headers": summary_headers,
|
|
340
|
+
"body_length": full_len,
|
|
341
|
+
"body_preview": preview,
|
|
342
|
+
"body_truncated": full_len > len(preview.encode()),
|
|
343
|
+
"redirected": bool(resp.history),
|
|
344
|
+
"elapsed_ms": elapsed_ms,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async def aclose(self) -> None:
|
|
348
|
+
"""Close the HTTP client if this tool created it."""
|
|
349
|
+
if self._client is not None and self._owns_client:
|
|
350
|
+
await self._client.aclose()
|
|
351
|
+
self._client = None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Nuclei template-scan tool.
|
|
2
|
+
|
|
3
|
+
Skeleton only.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NucleiTool:
|
|
12
|
+
"""Run nuclei templates against an in-scope target."""
|
|
13
|
+
|
|
14
|
+
name = "nuclei"
|
|
15
|
+
|
|
16
|
+
def schema(self) -> dict[str, Any]:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
async def run(self, **kwargs: Any) -> Any:
|
|
20
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""``report_finding`` tool — the only way the agent records a candidate finding.
|
|
2
|
+
|
|
3
|
+
Two hard guarantees, enforced here rather than trusted to the prompt:
|
|
4
|
+
|
|
5
|
+
1. **Strict shape.** The submission is validated against :class:`FindingReport`
|
|
6
|
+
(``extra="forbid"``, no verdict fields). The agent cannot add fields, and
|
|
7
|
+
it cannot declare a finding "confirmed"/"vulnerable" — that vocabulary
|
|
8
|
+
simply does not exist in the schema.
|
|
9
|
+
|
|
10
|
+
2. **Grounded evidence.** Every ``request_id`` the finding cites must refer to
|
|
11
|
+
a real exchange in the :class:`EvidenceStore`. If any cited id is unknown,
|
|
12
|
+
the report is rejected and the agent is told to make the request first.
|
|
13
|
+
This makes fabricated or hallucinated evidence impossible to report.
|
|
14
|
+
|
|
15
|
+
The tool records a *candidate* (status ``pending_verification``). It never
|
|
16
|
+
renders a verdict — the deterministic oracle does that downstream.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from pydantic import ValidationError
|
|
24
|
+
|
|
25
|
+
from backend.schemas.api import FindingReport
|
|
26
|
+
from backend.verification.evidence_store import EvidenceStore
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ReportFindingTool:
|
|
30
|
+
"""Validate, ground, and record a candidate finding."""
|
|
31
|
+
|
|
32
|
+
name = "report_finding"
|
|
33
|
+
|
|
34
|
+
def __init__(self, evidence_store: EvidenceStore) -> None:
|
|
35
|
+
self._evidence = evidence_store
|
|
36
|
+
self._findings: list[tuple[str, FindingReport]] = []
|
|
37
|
+
self._counter = 0
|
|
38
|
+
|
|
39
|
+
def schema(self) -> dict[str, Any]:
|
|
40
|
+
"""OpenAI-style function schema mirroring :class:`FindingReport`.
|
|
41
|
+
|
|
42
|
+
The advertised schema guides the model; :class:`FindingReport` enforces
|
|
43
|
+
it strictly on the way in. Note the absence of any verdict field.
|
|
44
|
+
"""
|
|
45
|
+
return {
|
|
46
|
+
"type": "function",
|
|
47
|
+
"function": {
|
|
48
|
+
"name": self.name,
|
|
49
|
+
"description": (
|
|
50
|
+
"Report a CANDIDATE access-control finding for deterministic "
|
|
51
|
+
"verification. You do NOT decide whether it is vulnerable — a "
|
|
52
|
+
"separate oracle renders the verdict. Every request_id you cite "
|
|
53
|
+
"MUST come from an http_request you actually made."
|
|
54
|
+
),
|
|
55
|
+
"parameters": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {
|
|
58
|
+
"title": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"description": "Short title, e.g. 'IDOR on GET /api/orders/{id}'.",
|
|
61
|
+
},
|
|
62
|
+
"hypothesis": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"description": "The specific access-control hypothesis tested.",
|
|
65
|
+
},
|
|
66
|
+
"vuln_class": {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"properties": {
|
|
69
|
+
"cwe": {"type": "string", "description": "e.g. 'CWE-639'."},
|
|
70
|
+
"owasp": {"type": "string", "description": "e.g. 'A01:2021'."},
|
|
71
|
+
},
|
|
72
|
+
"required": ["cwe"],
|
|
73
|
+
},
|
|
74
|
+
"evidence": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"baseline": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "request_id: identity A reading A's OWN object.",
|
|
80
|
+
},
|
|
81
|
+
"cross_access": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"description": "request_id: identity A requesting B's object.",
|
|
84
|
+
},
|
|
85
|
+
"controls": {
|
|
86
|
+
"type": "array",
|
|
87
|
+
"items": {"type": "string"},
|
|
88
|
+
"description": "request_ids of negative controls.",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
"required": ["baseline", "cross_access"],
|
|
92
|
+
},
|
|
93
|
+
"rationale": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": "Concise factual reasoning. NOT a verdict.",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
"required": ["title", "hypothesis", "vuln_class", "evidence"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async def run(self, **kwargs: Any) -> dict[str, Any]:
|
|
104
|
+
"""Validate the submission, verify its citations, and record a candidate."""
|
|
105
|
+
# 1. Strict shape — reject invented fields and any verdict vocabulary.
|
|
106
|
+
try:
|
|
107
|
+
report = FindingReport.model_validate(kwargs)
|
|
108
|
+
except ValidationError as exc:
|
|
109
|
+
return {
|
|
110
|
+
"error": "invalid_finding",
|
|
111
|
+
"detail": exc.errors(include_url=False, include_context=False),
|
|
112
|
+
"note": "Fix the fields and resubmit. You cannot add fields or declare a verdict.",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# 2. Grounding — every cited request_id must be real evidence.
|
|
116
|
+
cited = report.cited_request_ids()
|
|
117
|
+
missing = [rid for rid in cited if not self._evidence.has(rid)]
|
|
118
|
+
if missing:
|
|
119
|
+
return {
|
|
120
|
+
"error": "unknown_request_id",
|
|
121
|
+
"missing": missing,
|
|
122
|
+
"note": (
|
|
123
|
+
"Each request_id must come from an http_request you actually made. "
|
|
124
|
+
"Make the request first, then cite the request_id it returned."
|
|
125
|
+
),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# 3. Record as a candidate — pending verification, NOT a verdict.
|
|
129
|
+
self._counter += 1
|
|
130
|
+
finding_id = f"f{self._counter}"
|
|
131
|
+
self._findings.append((finding_id, report))
|
|
132
|
+
return {
|
|
133
|
+
"finding_id": finding_id,
|
|
134
|
+
"status": "pending_verification",
|
|
135
|
+
"cited_request_ids": cited,
|
|
136
|
+
"note": (
|
|
137
|
+
"Recorded as a candidate. A deterministic oracle will render the "
|
|
138
|
+
"verdict — you have not confirmed anything."
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def findings(self) -> list[tuple[str, FindingReport]]:
|
|
144
|
+
"""Recorded candidate findings, in submission order."""
|
|
145
|
+
return list(self._findings)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Raw shell tool(s) for CTF mode.
|
|
2
|
+
|
|
3
|
+
Capability-isolated to CTF: this tool is never registered in Enterprise mode,
|
|
4
|
+
so the LLM never sees it and the executor can never dispatch it there.
|
|
5
|
+
|
|
6
|
+
Skeleton only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ShellTool:
|
|
15
|
+
"""Execute an arbitrary shell command (CTF mode only)."""
|
|
16
|
+
|
|
17
|
+
name = "shell"
|
|
18
|
+
|
|
19
|
+
def schema(self) -> dict[str, Any]:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
async def run(self, **kwargs: Any) -> Any:
|
|
23
|
+
raise NotImplementedError
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deterministic verification: evidence storage and the IDOR oracle (the judge)."""
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Append-only store of raw request/response pairs, keyed by ``request_id``.
|
|
2
|
+
|
|
3
|
+
This is the single source of truth for what actually happened on the wire. The
|
|
4
|
+
agent never holds raw evidence: every ``http_request`` exchange is recorded here
|
|
5
|
+
in full and the agent gets back only a ``request_id`` plus a compact summary.
|
|
6
|
+
The agent then refers to evidence ONLY by ``request_id`` — when reporting a
|
|
7
|
+
finding, when reasoning — so it is structurally incapable of inventing or
|
|
8
|
+
paraphrasing request/response content.
|
|
9
|
+
|
|
10
|
+
Consumers of the *full* exchange are server-side, never the LLM:
|
|
11
|
+
* the deterministic IDOR oracle, which compares exchanges to render a verdict;
|
|
12
|
+
* the ``GET /api/scans/{id}/evidence/{req_id}`` endpoint, which renders the
|
|
13
|
+
raw request/response side by side for a human operator.
|
|
14
|
+
|
|
15
|
+
Because the stored request includes the credentials the system injected for an
|
|
16
|
+
identity, the store is a server-side artifact and is never serialized back to
|
|
17
|
+
the model.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from backend.db.repository import ScanRepository
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class HttpExchange:
|
|
32
|
+
"""One fully-recorded HTTP request/response, addressable by ``request_id``."""
|
|
33
|
+
|
|
34
|
+
request_id: str
|
|
35
|
+
request: dict[str, Any]
|
|
36
|
+
response: dict[str, Any]
|
|
37
|
+
ts: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict[str, Any]:
|
|
40
|
+
"""Full server-side view (request + response). Not for the LLM."""
|
|
41
|
+
return {
|
|
42
|
+
"request_id": self.request_id,
|
|
43
|
+
"ts": self.ts,
|
|
44
|
+
"request": self.request,
|
|
45
|
+
"response": self.response,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class EvidenceStore:
|
|
50
|
+
"""Per-scan store of HTTP exchanges, addressable by ``request_id``.
|
|
51
|
+
|
|
52
|
+
IDs are sequential and human-readable (``r1``, ``r2``, …) so they read
|
|
53
|
+
cleanly in findings and the Task Tree. The store is append-only — an
|
|
54
|
+
exchange, once recorded, is never mutated.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, scan_id: str, repo: ScanRepository | None = None) -> None:
|
|
58
|
+
self.scan_id = scan_id
|
|
59
|
+
self._exchanges: dict[str, HttpExchange] = {}
|
|
60
|
+
self._counter = 0
|
|
61
|
+
# Durable backing store (optional). When set, ``put`` records each
|
|
62
|
+
# exchange in a write-behind buffer that ``flush`` drains to the DB.
|
|
63
|
+
self._repo = repo
|
|
64
|
+
self._unflushed: list[HttpExchange] = []
|
|
65
|
+
|
|
66
|
+
def put(self, request: dict[str, Any], response: dict[str, Any]) -> str:
|
|
67
|
+
"""Persist a full exchange and return its freshly minted ``request_id``.
|
|
68
|
+
|
|
69
|
+
Runs synchronously with no ``await`` inside, so it is atomic with
|
|
70
|
+
respect to the event loop even when tool calls fan out concurrently —
|
|
71
|
+
no two exchanges can collide on an ID. When a repository is configured
|
|
72
|
+
the exchange is also queued for write-behind persistence (drained by
|
|
73
|
+
:meth:`flush`); the in-memory cache stays the authoritative, synchronous
|
|
74
|
+
view that grounding (:meth:`has`) and the oracle (:meth:`get`) read from.
|
|
75
|
+
"""
|
|
76
|
+
self._counter += 1
|
|
77
|
+
request_id = f"r{self._counter}"
|
|
78
|
+
exchange = HttpExchange(
|
|
79
|
+
request_id=request_id,
|
|
80
|
+
request=request,
|
|
81
|
+
response=response,
|
|
82
|
+
)
|
|
83
|
+
self._exchanges[request_id] = exchange
|
|
84
|
+
if self._repo is not None:
|
|
85
|
+
self._unflushed.append(exchange)
|
|
86
|
+
return request_id
|
|
87
|
+
|
|
88
|
+
def get(self, request_id: str) -> dict[str, Any] | None:
|
|
89
|
+
"""Fetch the full stored exchange by ``request_id``; ``None`` if unknown.
|
|
90
|
+
|
|
91
|
+
Reads the in-memory cache only — synchronous and authoritative for the
|
|
92
|
+
life of the run. For a cross-process read (e.g. the evidence endpoint
|
|
93
|
+
after a restart) use :meth:`aget`, which falls back to the database.
|
|
94
|
+
"""
|
|
95
|
+
exchange = self._exchanges.get(request_id)
|
|
96
|
+
return exchange.as_dict() if exchange is not None else None
|
|
97
|
+
|
|
98
|
+
async def aget(self, request_id: str) -> dict[str, Any] | None:
|
|
99
|
+
"""Async fetch: the in-memory cache first, then the database if configured."""
|
|
100
|
+
cached = self.get(request_id)
|
|
101
|
+
if cached is not None or self._repo is None:
|
|
102
|
+
return cached
|
|
103
|
+
return await self._repo.get_exchange(self.scan_id, request_id)
|
|
104
|
+
|
|
105
|
+
def has(self, request_id: str) -> bool:
|
|
106
|
+
"""Whether ``request_id`` refers to a real, recorded exchange.
|
|
107
|
+
|
|
108
|
+
The grounding check: ``report_finding`` uses this to reject any finding
|
|
109
|
+
that cites a ``request_id`` the agent did not actually produce.
|
|
110
|
+
"""
|
|
111
|
+
return request_id in self._exchanges
|
|
112
|
+
|
|
113
|
+
async def flush(self) -> None:
|
|
114
|
+
"""Drain buffered exchanges to the database (no-op without a repository).
|
|
115
|
+
|
|
116
|
+
Swaps the buffer out before awaiting so concurrent :meth:`put` calls
|
|
117
|
+
accumulate into the next batch rather than racing this one.
|
|
118
|
+
"""
|
|
119
|
+
if self._repo is None or not self._unflushed:
|
|
120
|
+
return
|
|
121
|
+
batch, self._unflushed = self._unflushed, []
|
|
122
|
+
await self._repo.add_exchanges(self.scan_id, batch)
|
|
123
|
+
|
|
124
|
+
def __len__(self) -> int:
|
|
125
|
+
return len(self._exchanges)
|