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.
Files changed (95) hide show
  1. agent/__init__.py +17 -0
  2. agent/benchmark/__init__.py +11 -0
  3. agent/benchmark/cli.py +179 -0
  4. agent/benchmark/config.py +15 -0
  5. agent/benchmark/docker.py +192 -0
  6. agent/benchmark/registry.py +99 -0
  7. agent/core/__init__.py +0 -0
  8. agent/core/agent.py +362 -0
  9. agent/core/backend.py +1667 -0
  10. agent/core/config.py +106 -0
  11. agent/core/controller.py +638 -0
  12. agent/core/events.py +177 -0
  13. agent/core/langfuse.py +320 -0
  14. agent/core/phantom.py +2327 -0
  15. agent/core/planner.py +493 -0
  16. agent/core/profiling.py +58 -0
  17. agent/core/sanitizer.py +104 -0
  18. agent/core/session.py +228 -0
  19. agent/core/tracer.py +137 -0
  20. agent/interface/__init__.py +0 -0
  21. agent/interface/components/__init__.py +0 -0
  22. agent/interface/components/activity_feed.py +202 -0
  23. agent/interface/components/renderers.py +149 -0
  24. agent/interface/components/splash.py +112 -0
  25. agent/interface/main.py +1126 -0
  26. agent/interface/styles.tcss +421 -0
  27. agent/interface/tui.py +508 -0
  28. agent/parsing/html_distiller.py +230 -0
  29. agent/parsing/tool_parser.py +115 -0
  30. agent/prompts/__init__.py +0 -0
  31. agent/prompts/pentesting.py +238 -0
  32. agent/rag_module/knowledge_base.py +116 -0
  33. agent/tests/benchmark_phantom.py +455 -0
  34. agent/tools/__init__.py +14 -0
  35. agent/tools/base.py +99 -0
  36. agent/tools/executor.py +345 -0
  37. agent/tools/registry.py +47 -0
  38. backend/README.md +244 -0
  39. backend/__init__.py +10 -0
  40. backend/api/__init__.py +1 -0
  41. backend/api/routes_scan.py +932 -0
  42. backend/authz.py +75 -0
  43. backend/cli.py +261 -0
  44. backend/compliance/__init__.py +1 -0
  45. backend/compliance/pdpa_mapping.py +16 -0
  46. backend/core/__init__.py +1 -0
  47. backend/core/coverage.py +93 -0
  48. backend/core/events.py +166 -0
  49. backend/core/llm_client.py +614 -0
  50. backend/core/orchestrator.py +402 -0
  51. backend/core/scan_memory.py +236 -0
  52. backend/crawler/__init__.py +1 -0
  53. backend/crawler/csrf.py +75 -0
  54. backend/crawler/headless.py +232 -0
  55. backend/crawler/js_analyzer.py +133 -0
  56. backend/crawler/spider.py +550 -0
  57. backend/crawler/subdomains.py +279 -0
  58. backend/daemon.py +252 -0
  59. backend/db/README.md +86 -0
  60. backend/db/__init__.py +17 -0
  61. backend/db/engine.py +87 -0
  62. backend/db/models.py +206 -0
  63. backend/db/repository.py +316 -0
  64. backend/db/schema.sql +247 -0
  65. backend/modes/__init__.py +5 -0
  66. backend/modes/base.py +178 -0
  67. backend/modes/ctf.py +39 -0
  68. backend/modes/enterprise.py +210 -0
  69. backend/modes/general.py +226 -0
  70. backend/modes/registry.py +34 -0
  71. backend/reporting/__init__.py +0 -0
  72. backend/reporting/generator.py +285 -0
  73. backend/schemas/__init__.py +1 -0
  74. backend/schemas/api.py +423 -0
  75. backend/tools/__init__.py +62 -0
  76. backend/tools/dirbrute_tool.py +304 -0
  77. backend/tools/general_report_tool.py +135 -0
  78. backend/tools/http_tool.py +351 -0
  79. backend/tools/nuclei_tool.py +20 -0
  80. backend/tools/report_tool.py +145 -0
  81. backend/tools/shell_tools.py +23 -0
  82. backend/verification/__init__.py +1 -0
  83. backend/verification/evidence_store.py +125 -0
  84. backend/verification/general_oracle.py +369 -0
  85. backend/verification/idor_oracle.py +131 -0
  86. backend/waf/__init__.py +0 -0
  87. backend/waf/detector.py +147 -0
  88. backend/waf/evasion.py +117 -0
  89. backend/webui/index.html +713 -0
  90. backend/workspace.py +182 -0
  91. shadowcat-2.0.0.dist-info/METADATA +360 -0
  92. shadowcat-2.0.0.dist-info/RECORD +95 -0
  93. shadowcat-2.0.0.dist-info/WHEEL +4 -0
  94. shadowcat-2.0.0.dist-info/entry_points.txt +4 -0
  95. 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)