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,304 @@
|
|
|
1
|
+
"""Directory / file brute-force tool — discovers unlisted paths.
|
|
2
|
+
|
|
3
|
+
Tries a built-in wordlist of ~180 high-value paths (admin panels, API docs,
|
|
4
|
+
config files, backup files, framework debug endpoints) and reports any that
|
|
5
|
+
return HTTP 200, 301, 302, 401, or 403. Skips everything else.
|
|
6
|
+
|
|
7
|
+
Registered in GeneralDastMode so the agent can call it by name.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_WORDLIST: list[str] = [
|
|
21
|
+
# Admin panels
|
|
22
|
+
"admin",
|
|
23
|
+
"administrator",
|
|
24
|
+
"admin/login",
|
|
25
|
+
"admin/dashboard",
|
|
26
|
+
"manage",
|
|
27
|
+
"management",
|
|
28
|
+
"backend",
|
|
29
|
+
"backoffice",
|
|
30
|
+
"cp",
|
|
31
|
+
"cpanel",
|
|
32
|
+
"dashboard",
|
|
33
|
+
"panel",
|
|
34
|
+
# API
|
|
35
|
+
"api",
|
|
36
|
+
"api/v1",
|
|
37
|
+
"api/v2",
|
|
38
|
+
"api/v3",
|
|
39
|
+
"api/v4",
|
|
40
|
+
"rest",
|
|
41
|
+
"graphql",
|
|
42
|
+
"gql",
|
|
43
|
+
"rpc",
|
|
44
|
+
"swagger",
|
|
45
|
+
"swagger-ui",
|
|
46
|
+
"swagger.json",
|
|
47
|
+
"openapi.json",
|
|
48
|
+
"api-docs",
|
|
49
|
+
"api/swagger",
|
|
50
|
+
"api/docs",
|
|
51
|
+
# Debug / dev
|
|
52
|
+
"debug",
|
|
53
|
+
"console",
|
|
54
|
+
"shell",
|
|
55
|
+
"test",
|
|
56
|
+
"dev",
|
|
57
|
+
"phpinfo.php",
|
|
58
|
+
"info.php",
|
|
59
|
+
# Secrets / config
|
|
60
|
+
".env",
|
|
61
|
+
".env.local",
|
|
62
|
+
".env.production",
|
|
63
|
+
"config.php",
|
|
64
|
+
"config.json",
|
|
65
|
+
"config.yml",
|
|
66
|
+
"config.yaml",
|
|
67
|
+
".git/config",
|
|
68
|
+
".git/HEAD",
|
|
69
|
+
".htaccess",
|
|
70
|
+
".htpasswd",
|
|
71
|
+
"web.config",
|
|
72
|
+
"package.json",
|
|
73
|
+
# Auth
|
|
74
|
+
"login",
|
|
75
|
+
"signin",
|
|
76
|
+
"auth",
|
|
77
|
+
"authentication",
|
|
78
|
+
"logout",
|
|
79
|
+
"register",
|
|
80
|
+
"signup",
|
|
81
|
+
"forgot-password",
|
|
82
|
+
"reset-password",
|
|
83
|
+
"oauth",
|
|
84
|
+
"oauth2",
|
|
85
|
+
"sso",
|
|
86
|
+
# Users / profile
|
|
87
|
+
"user",
|
|
88
|
+
"users",
|
|
89
|
+
"profile",
|
|
90
|
+
"account",
|
|
91
|
+
"accounts",
|
|
92
|
+
"me",
|
|
93
|
+
# Files
|
|
94
|
+
"upload",
|
|
95
|
+
"uploads",
|
|
96
|
+
"files",
|
|
97
|
+
"media",
|
|
98
|
+
"download",
|
|
99
|
+
"downloads",
|
|
100
|
+
"static",
|
|
101
|
+
"assets",
|
|
102
|
+
"public",
|
|
103
|
+
# Health / metrics (cloud-native)
|
|
104
|
+
"health",
|
|
105
|
+
"healthz",
|
|
106
|
+
"health-check",
|
|
107
|
+
"ping",
|
|
108
|
+
"status",
|
|
109
|
+
"actuator",
|
|
110
|
+
"actuator/health",
|
|
111
|
+
"actuator/info",
|
|
112
|
+
"actuator/metrics",
|
|
113
|
+
"actuator/env",
|
|
114
|
+
"metrics",
|
|
115
|
+
"prometheus",
|
|
116
|
+
# Database admin
|
|
117
|
+
"phpMyAdmin",
|
|
118
|
+
"phpmyadmin",
|
|
119
|
+
"pma",
|
|
120
|
+
"adminer",
|
|
121
|
+
"adminer.php",
|
|
122
|
+
"server-status",
|
|
123
|
+
"server-info",
|
|
124
|
+
# WordPress
|
|
125
|
+
"wp-admin",
|
|
126
|
+
"wp-login.php",
|
|
127
|
+
"wp-json",
|
|
128
|
+
"xmlrpc.php",
|
|
129
|
+
"wp-content/uploads",
|
|
130
|
+
"wp-json/wp/v2/users",
|
|
131
|
+
# Framework-specific
|
|
132
|
+
"telescope",
|
|
133
|
+
"horizon", # Laravel
|
|
134
|
+
"_debug",
|
|
135
|
+
"_debug/toolbar", # Django debug toolbar
|
|
136
|
+
"rails/info",
|
|
137
|
+
"rails/info/routes", # Rails
|
|
138
|
+
# Backups
|
|
139
|
+
"backup",
|
|
140
|
+
"backup.sql",
|
|
141
|
+
"database.sql",
|
|
142
|
+
"dump.sql",
|
|
143
|
+
"backup.zip",
|
|
144
|
+
"backup.tar.gz",
|
|
145
|
+
"old",
|
|
146
|
+
"archive",
|
|
147
|
+
# SEO / well-known
|
|
148
|
+
"robots.txt",
|
|
149
|
+
"sitemap.xml",
|
|
150
|
+
"sitemap_index.xml",
|
|
151
|
+
"security.txt",
|
|
152
|
+
".well-known/security.txt",
|
|
153
|
+
"humans.txt",
|
|
154
|
+
# Docs
|
|
155
|
+
"docs",
|
|
156
|
+
"documentation",
|
|
157
|
+
"changelog",
|
|
158
|
+
"version",
|
|
159
|
+
"version.txt",
|
|
160
|
+
# Install / setup
|
|
161
|
+
"setup",
|
|
162
|
+
"setup.php",
|
|
163
|
+
"install",
|
|
164
|
+
"install.php",
|
|
165
|
+
"update.php",
|
|
166
|
+
"cgi-bin",
|
|
167
|
+
# Logs / temp
|
|
168
|
+
"logs",
|
|
169
|
+
"log",
|
|
170
|
+
"tmp",
|
|
171
|
+
"temp",
|
|
172
|
+
"cache",
|
|
173
|
+
# Common REST resources
|
|
174
|
+
"posts",
|
|
175
|
+
"articles",
|
|
176
|
+
"products",
|
|
177
|
+
"items",
|
|
178
|
+
"orders",
|
|
179
|
+
"payments",
|
|
180
|
+
"billing",
|
|
181
|
+
"notifications",
|
|
182
|
+
"messages",
|
|
183
|
+
"comments",
|
|
184
|
+
# Java Spring Boot
|
|
185
|
+
"actuator/beans",
|
|
186
|
+
"actuator/mappings",
|
|
187
|
+
"actuator/configprops",
|
|
188
|
+
# Misc
|
|
189
|
+
"private",
|
|
190
|
+
"secret",
|
|
191
|
+
"secrets",
|
|
192
|
+
"credentials",
|
|
193
|
+
"info",
|
|
194
|
+
"about",
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
_INTERESTING_STATUSES = frozenset({200, 301, 302, 401, 403})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class DirBruteTool:
|
|
201
|
+
"""Async directory brute-force over a built-in wordlist.
|
|
202
|
+
|
|
203
|
+
Uses N concurrent workers (default 20) that each probe one path at a time.
|
|
204
|
+
The wordlist is partitioned statically across workers to avoid queue
|
|
205
|
+
complexity since paths are never added dynamically.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
name = "dir_bruteforce"
|
|
209
|
+
|
|
210
|
+
def __init__(
|
|
211
|
+
self,
|
|
212
|
+
*,
|
|
213
|
+
workers: int = 20,
|
|
214
|
+
timeout: float = 10.0,
|
|
215
|
+
rate_limit_ms: int = 0,
|
|
216
|
+
) -> None:
|
|
217
|
+
self._workers = workers
|
|
218
|
+
self._timeout = timeout
|
|
219
|
+
self._rate_limit_ms = rate_limit_ms
|
|
220
|
+
|
|
221
|
+
def schema(self) -> dict[str, Any]:
|
|
222
|
+
return {
|
|
223
|
+
"type": "function",
|
|
224
|
+
"function": {
|
|
225
|
+
"name": self.name,
|
|
226
|
+
"description": (
|
|
227
|
+
"Brute-force ~180 common directory and file paths on the target to "
|
|
228
|
+
"discover hidden attack surface: admin panels, API docs, config files, "
|
|
229
|
+
"backup files, and framework debug endpoints. "
|
|
230
|
+
"Reports paths that return HTTP 200 (accessible), 401/403 (exists but "
|
|
231
|
+
"protected), or 301/302 (redirect). Use this at the start of an assessment "
|
|
232
|
+
"before probing individual endpoints."
|
|
233
|
+
),
|
|
234
|
+
"parameters": {
|
|
235
|
+
"type": "object",
|
|
236
|
+
"properties": {
|
|
237
|
+
"rationale": {
|
|
238
|
+
"type": "string",
|
|
239
|
+
"description": "Why you are running this brute-force scan now.",
|
|
240
|
+
},
|
|
241
|
+
"base_url": {
|
|
242
|
+
"type": "string",
|
|
243
|
+
"description": "Base URL to probe (e.g. 'https://target.com'). "
|
|
244
|
+
"Paths are appended: base_url/admin, base_url/.env, etc.",
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
"required": ["rationale", "base_url"],
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async def run(self, **kwargs: Any) -> dict[str, Any]:
|
|
253
|
+
base_url = (kwargs.get("base_url") or "").rstrip("/")
|
|
254
|
+
if not base_url:
|
|
255
|
+
return {"error": "missing_parameter", "detail": "base_url is required"}
|
|
256
|
+
|
|
257
|
+
found: list[dict[str, Any]] = []
|
|
258
|
+
result_lock = asyncio.Lock()
|
|
259
|
+
|
|
260
|
+
# Partition wordlist across workers.
|
|
261
|
+
chunk_size = max(1, (len(_WORDLIST) + self._workers - 1) // self._workers)
|
|
262
|
+
chunks = [_WORDLIST[i : i + chunk_size] for i in range(0, len(_WORDLIST), chunk_size)]
|
|
263
|
+
|
|
264
|
+
async def _probe_batch(client: httpx.AsyncClient, paths: list[str]) -> None:
|
|
265
|
+
for path in paths:
|
|
266
|
+
if self._rate_limit_ms > 0:
|
|
267
|
+
await asyncio.sleep(self._rate_limit_ms / 1000)
|
|
268
|
+
url = f"{base_url}/{path}"
|
|
269
|
+
try:
|
|
270
|
+
resp = await client.get(url, follow_redirects=False, timeout=self._timeout)
|
|
271
|
+
if resp.status_code in _INTERESTING_STATUSES:
|
|
272
|
+
ct = resp.headers.get("content-type", "")
|
|
273
|
+
preview = resp.text[:300] if resp.text else ""
|
|
274
|
+
async with result_lock:
|
|
275
|
+
found.append(
|
|
276
|
+
{
|
|
277
|
+
"path": path,
|
|
278
|
+
"url": url,
|
|
279
|
+
"status": resp.status_code,
|
|
280
|
+
"content_type": ct,
|
|
281
|
+
"body_preview": preview,
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
log.info("DirBrute: %s → %d", url, resp.status_code)
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
log.debug("DirBrute error %s: %s", url, exc)
|
|
287
|
+
|
|
288
|
+
async with httpx.AsyncClient(
|
|
289
|
+
verify=False,
|
|
290
|
+
follow_redirects=False,
|
|
291
|
+
headers={
|
|
292
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
293
|
+
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
|
294
|
+
},
|
|
295
|
+
) as client:
|
|
296
|
+
await asyncio.gather(*[_probe_batch(client, chunk) for chunk in chunks])
|
|
297
|
+
|
|
298
|
+
found.sort(key=lambda x: (x["status"], x["path"]))
|
|
299
|
+
return {
|
|
300
|
+
"total_paths_tested": len(_WORDLIST),
|
|
301
|
+
"found_count": len(found),
|
|
302
|
+
"found_paths": found,
|
|
303
|
+
"note": "200=accessible, 401/403=exists-but-protected, 301/302=redirected.",
|
|
304
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""``report_finding`` tool for General DAST mode.
|
|
2
|
+
|
|
3
|
+
Same guarantees as the Enterprise variant but with a more flexible evidence
|
|
4
|
+
schema: a single ``probe`` request_id is the minimum (no mandatory
|
|
5
|
+
``baseline`` / ``cross_access`` pair required for IDOR). The agent still cannot
|
|
6
|
+
add fields or declare a verdict — the schema forbids both.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from backend.schemas.api import GeneralFindingReport
|
|
16
|
+
from backend.verification.evidence_store import EvidenceStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ReportGeneralFindingTool:
|
|
20
|
+
"""Validate, ground, and record a General DAST candidate finding."""
|
|
21
|
+
|
|
22
|
+
name = "report_finding"
|
|
23
|
+
|
|
24
|
+
def __init__(self, evidence_store: EvidenceStore) -> None:
|
|
25
|
+
self._evidence = evidence_store
|
|
26
|
+
self._findings: list[tuple[str, GeneralFindingReport]] = []
|
|
27
|
+
self._counter = 0
|
|
28
|
+
|
|
29
|
+
def schema(self) -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"type": "function",
|
|
32
|
+
"function": {
|
|
33
|
+
"name": self.name,
|
|
34
|
+
"description": (
|
|
35
|
+
"Report a CANDIDATE vulnerability finding for deterministic verification. "
|
|
36
|
+
"You do NOT decide whether it is real — a separate oracle renders the verdict. "
|
|
37
|
+
"Every request_id you cite MUST come from an http_request you actually made this session."
|
|
38
|
+
),
|
|
39
|
+
"parameters": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {
|
|
42
|
+
"title": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "Short title, e.g. 'Reflected XSS on GET /search?q='.",
|
|
45
|
+
},
|
|
46
|
+
"hypothesis": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"description": "The specific vulnerability hypothesis being tested.",
|
|
49
|
+
},
|
|
50
|
+
"vuln_class": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"cwe": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "CWE identifier, e.g. 'CWE-89' for SQLi.",
|
|
56
|
+
},
|
|
57
|
+
"owasp": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "OWASP Top 10 category, e.g. 'A03:2021'.",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
"required": ["cwe"],
|
|
63
|
+
},
|
|
64
|
+
"evidence": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"properties": {
|
|
67
|
+
"probe": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": "request_id of the test request that elicited the anomalous response.",
|
|
70
|
+
},
|
|
71
|
+
"baseline": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "request_id of a benign baseline request (same endpoint, safe input).",
|
|
74
|
+
},
|
|
75
|
+
"controls": {
|
|
76
|
+
"type": "array",
|
|
77
|
+
"items": {"type": "string"},
|
|
78
|
+
"description": "request_ids of additional variant requests.",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
"required": ["probe"],
|
|
82
|
+
},
|
|
83
|
+
"payload": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"description": "The exact test payload used (e.g. SQLi string, XSS vector, traversal path).",
|
|
86
|
+
},
|
|
87
|
+
"rationale": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"description": "Concise factual reasoning. NOT a verdict — just explain what you observed.",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
"required": ["title", "hypothesis", "vuln_class", "evidence"],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async def run(self, **kwargs: Any) -> dict[str, Any]:
|
|
98
|
+
"""Validate, ground, and record a candidate finding."""
|
|
99
|
+
try:
|
|
100
|
+
report = GeneralFindingReport.model_validate(kwargs)
|
|
101
|
+
except ValidationError as exc:
|
|
102
|
+
return {
|
|
103
|
+
"error": "invalid_finding",
|
|
104
|
+
"detail": exc.errors(include_url=False, include_context=False),
|
|
105
|
+
"note": "Fix the fields and resubmit. You cannot add fields or declare a verdict.",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
cited = report.cited_request_ids()
|
|
109
|
+
missing = [rid for rid in cited if not self._evidence.has(rid)]
|
|
110
|
+
if missing:
|
|
111
|
+
return {
|
|
112
|
+
"error": "unknown_request_id",
|
|
113
|
+
"missing": missing,
|
|
114
|
+
"note": (
|
|
115
|
+
"Each request_id must come from an http_request you actually made. "
|
|
116
|
+
"Make the request first, then cite its request_id."
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
self._counter += 1
|
|
121
|
+
finding_id = f"f{self._counter}"
|
|
122
|
+
self._findings.append((finding_id, report))
|
|
123
|
+
return {
|
|
124
|
+
"finding_id": finding_id,
|
|
125
|
+
"status": "pending_verification",
|
|
126
|
+
"cited_request_ids": cited,
|
|
127
|
+
"note": (
|
|
128
|
+
"Recorded as a candidate. The oracle renders the verdict — "
|
|
129
|
+
"you have not confirmed anything."
|
|
130
|
+
),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def findings(self) -> list[tuple[str, GeneralFindingReport]]:
|
|
135
|
+
return list(self._findings)
|