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,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)