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,550 @@
|
|
|
1
|
+
"""Async concurrent BFS web spider for pre-scan attack surface discovery.
|
|
2
|
+
|
|
3
|
+
Runs before the LLM agent to give it a complete map of endpoints, forms, and
|
|
4
|
+
interesting parameters so the agent spends its budget testing, not exploring.
|
|
5
|
+
|
|
6
|
+
Improvements over the initial version:
|
|
7
|
+
Phase 1 — Authenticated crawling: accepts session cookies so the spider sees
|
|
8
|
+
authenticated pages the anonymous spider misses.
|
|
9
|
+
Phase 3 — JavaScript API discovery: after the HTML crawl, fetches linked .js
|
|
10
|
+
files and scans them for fetch/axios/route API path patterns.
|
|
11
|
+
Phase 6 — Concurrent worker pool: N workers (default 10) share a global
|
|
12
|
+
token-bucket rate limiter, giving ~10× throughput vs sequential
|
|
13
|
+
without changing the per-second request budget.
|
|
14
|
+
|
|
15
|
+
Design constraints (unchanged from v1):
|
|
16
|
+
- Zero new runtime deps (stdlib html.parser + httpx already present).
|
|
17
|
+
- Stays within authorized scope; skips static assets by extension.
|
|
18
|
+
- Parses robots.txt; honours Disallow unless respect_robots=False.
|
|
19
|
+
- Prioritises dynamic endpoints in the summary.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import contextlib
|
|
26
|
+
import logging
|
|
27
|
+
import os.path
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
import urllib.parse
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from html.parser import HTMLParser
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
_SKIP_EXTENSIONS = frozenset(
|
|
40
|
+
{
|
|
41
|
+
".css",
|
|
42
|
+
".js",
|
|
43
|
+
".ts",
|
|
44
|
+
".mjs",
|
|
45
|
+
".jsx",
|
|
46
|
+
".tsx",
|
|
47
|
+
".png",
|
|
48
|
+
".jpg",
|
|
49
|
+
".jpeg",
|
|
50
|
+
".gif",
|
|
51
|
+
".svg",
|
|
52
|
+
".webp",
|
|
53
|
+
".ico",
|
|
54
|
+
".avif",
|
|
55
|
+
".woff",
|
|
56
|
+
".woff2",
|
|
57
|
+
".ttf",
|
|
58
|
+
".eot",
|
|
59
|
+
".otf",
|
|
60
|
+
".pdf",
|
|
61
|
+
".zip",
|
|
62
|
+
".tar",
|
|
63
|
+
".gz",
|
|
64
|
+
".rar",
|
|
65
|
+
".7z",
|
|
66
|
+
".dmg",
|
|
67
|
+
".exe",
|
|
68
|
+
".mp4",
|
|
69
|
+
".mp3",
|
|
70
|
+
".avi",
|
|
71
|
+
".mov",
|
|
72
|
+
".webm",
|
|
73
|
+
".map",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
_ID_SEGMENT = re.compile(
|
|
78
|
+
r"(?:^|\/)(\d{1,12}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\/|$)",
|
|
79
|
+
re.IGNORECASE,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# HTML parser helpers
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class FormInfo:
|
|
90
|
+
page_url: str
|
|
91
|
+
action: str
|
|
92
|
+
method: str
|
|
93
|
+
fields: list[str] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
def summary(self) -> str:
|
|
96
|
+
fields_str = ", ".join(self.fields[:8]) or "(none)"
|
|
97
|
+
return f"{self.method} {self.action} [fields: {fields_str}]"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _PageParser(HTMLParser):
|
|
101
|
+
def __init__(self, base_url: str) -> None:
|
|
102
|
+
super().__init__()
|
|
103
|
+
self._base = base_url
|
|
104
|
+
self.links: list[str] = []
|
|
105
|
+
self.forms: list[FormInfo] = []
|
|
106
|
+
self._form: FormInfo | None = None
|
|
107
|
+
|
|
108
|
+
def _abs(self, href: str) -> str:
|
|
109
|
+
return urllib.parse.urljoin(self._base, href.strip())
|
|
110
|
+
|
|
111
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
112
|
+
a = dict(attrs)
|
|
113
|
+
tag = tag.lower()
|
|
114
|
+
href = a.get("href")
|
|
115
|
+
if tag in ("a", "area") and href:
|
|
116
|
+
self.links.append(self._abs(href))
|
|
117
|
+
elif tag == "form":
|
|
118
|
+
action = self._abs(a.get("action") or self._base)
|
|
119
|
+
self._form = FormInfo(
|
|
120
|
+
page_url=self._base,
|
|
121
|
+
action=action,
|
|
122
|
+
method=(a.get("method") or "get").upper(),
|
|
123
|
+
)
|
|
124
|
+
elif tag in ("input", "textarea", "select", "button") and self._form is not None:
|
|
125
|
+
name = a.get("name") or a.get("id")
|
|
126
|
+
if name and name not in ("submit", "reset", "button"):
|
|
127
|
+
self._form.fields.append(name)
|
|
128
|
+
|
|
129
|
+
def handle_endtag(self, tag: str) -> None:
|
|
130
|
+
if tag.lower() == "form" and self._form is not None:
|
|
131
|
+
self.forms.append(self._form)
|
|
132
|
+
self._form = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Attack surface output
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class AttackSurface:
|
|
142
|
+
"""Structured output from a Spider crawl."""
|
|
143
|
+
|
|
144
|
+
base_url: str
|
|
145
|
+
pages_visited: int = 0
|
|
146
|
+
dynamic_endpoints: list[str] = field(default_factory=list)
|
|
147
|
+
forms: list[FormInfo] = field(default_factory=list)
|
|
148
|
+
technologies: list[str] = field(default_factory=list)
|
|
149
|
+
robots_disallowed: list[str] = field(default_factory=list)
|
|
150
|
+
js_api_endpoints: list[str] = field(default_factory=list) # Phase 3
|
|
151
|
+
subdomains: list[str] = field(default_factory=list) # Phase 4
|
|
152
|
+
|
|
153
|
+
def summary(self, max_endpoints: int = 30, max_forms: int = 20) -> str:
|
|
154
|
+
lines: list[str] = [
|
|
155
|
+
f"PRE-SCAN ATTACK SURFACE ({self.base_url})",
|
|
156
|
+
f"Pages crawled: {self.pages_visited} | "
|
|
157
|
+
f"Dynamic endpoints: {len(self.dynamic_endpoints)} | "
|
|
158
|
+
f"Forms: {len(self.forms)} | "
|
|
159
|
+
f"JS API paths: {len(self.js_api_endpoints)}",
|
|
160
|
+
]
|
|
161
|
+
if self.technologies:
|
|
162
|
+
lines.append(f"Detected stack: {', '.join(self.technologies)}")
|
|
163
|
+
if self.subdomains:
|
|
164
|
+
lines.append(f"Live subdomains: {', '.join(self.subdomains[:15])}")
|
|
165
|
+
|
|
166
|
+
if self.forms:
|
|
167
|
+
lines.append("\nFORMS (test for SQLi, XSS, CSRF):")
|
|
168
|
+
for f in self.forms[:max_forms]:
|
|
169
|
+
lines.append(f" {f.summary()}")
|
|
170
|
+
if len(self.forms) > max_forms:
|
|
171
|
+
lines.append(f" … and {len(self.forms) - max_forms} more")
|
|
172
|
+
|
|
173
|
+
if self.dynamic_endpoints:
|
|
174
|
+
lines.append("\nDYNAMIC ENDPOINTS (test for IDOR, SQLi, traversal):")
|
|
175
|
+
for ep in self.dynamic_endpoints[:max_endpoints]:
|
|
176
|
+
lines.append(f" {ep}")
|
|
177
|
+
if len(self.dynamic_endpoints) > max_endpoints:
|
|
178
|
+
lines.append(f" … and {len(self.dynamic_endpoints) - max_endpoints} more")
|
|
179
|
+
|
|
180
|
+
if self.js_api_endpoints:
|
|
181
|
+
lines.append("\nJS-DISCOVERED API ENDPOINTS (not visible in HTML):")
|
|
182
|
+
for ep in self.js_api_endpoints[:20]:
|
|
183
|
+
lines.append(f" {ep}")
|
|
184
|
+
if len(self.js_api_endpoints) > 20:
|
|
185
|
+
lines.append(f" … and {len(self.js_api_endpoints) - 20} more")
|
|
186
|
+
|
|
187
|
+
if self.robots_disallowed:
|
|
188
|
+
interesting = [
|
|
189
|
+
p for p in self.robots_disallowed if "admin" in p.lower() or "api" in p.lower()
|
|
190
|
+
]
|
|
191
|
+
if interesting:
|
|
192
|
+
lines.append(
|
|
193
|
+
f"\nINTERESTING robots.txt Disallow paths: {', '.join(interesting[:10])}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
lines.append(
|
|
197
|
+
"\nFocus your testing budget on the forms and dynamic endpoints above. "
|
|
198
|
+
"Test each form field for SQLi and XSS. Test path parameters for IDOR and traversal."
|
|
199
|
+
)
|
|
200
|
+
return "\n".join(lines)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Spider
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Spider:
|
|
209
|
+
"""Concurrent BFS web crawler.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
allowed_hosts:
|
|
214
|
+
Additional hostnames / netlocs the crawler may follow links to.
|
|
215
|
+
The base URL's host is always allowed.
|
|
216
|
+
respect_robots:
|
|
217
|
+
Parse ``/robots.txt`` and skip disallowed paths when True.
|
|
218
|
+
timeout:
|
|
219
|
+
Per-request timeout in seconds.
|
|
220
|
+
workers:
|
|
221
|
+
Number of concurrent fetcher coroutines (Phase 6).
|
|
222
|
+
analyze_js:
|
|
223
|
+
Run JS API discovery after the HTML crawl (Phase 3).
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def __init__(
|
|
227
|
+
self,
|
|
228
|
+
*,
|
|
229
|
+
allowed_hosts: set[str] | None = None,
|
|
230
|
+
respect_robots: bool = True,
|
|
231
|
+
timeout: float = 15.0,
|
|
232
|
+
workers: int = 10,
|
|
233
|
+
analyze_js: bool = True,
|
|
234
|
+
render_js: bool = False,
|
|
235
|
+
) -> None:
|
|
236
|
+
self._allowed_hosts = allowed_hosts or set()
|
|
237
|
+
self._respect_robots = respect_robots
|
|
238
|
+
self._timeout = timeout
|
|
239
|
+
self._workers = workers
|
|
240
|
+
self._analyze_js = analyze_js
|
|
241
|
+
# Phase 7: render the root in a real headless browser so JS-rendered SPAs
|
|
242
|
+
# (which return an almost-empty shell over plain httpx) are seen properly.
|
|
243
|
+
self._render_js = render_js
|
|
244
|
+
|
|
245
|
+
async def crawl(
|
|
246
|
+
self,
|
|
247
|
+
start_url: str,
|
|
248
|
+
*,
|
|
249
|
+
max_pages: int = 150,
|
|
250
|
+
max_depth: int = 3,
|
|
251
|
+
rate_limit_ms: int = 300,
|
|
252
|
+
emitter: Any = None,
|
|
253
|
+
session_cookies: dict[str, str] | None = None, # Phase 1: auth crawl
|
|
254
|
+
) -> AttackSurface:
|
|
255
|
+
"""Run a concurrent BFS crawl from *start_url*."""
|
|
256
|
+
parsed = urllib.parse.urlparse(start_url)
|
|
257
|
+
base_netloc = parsed.netloc
|
|
258
|
+
allowed = self._allowed_hosts | {base_netloc}
|
|
259
|
+
surface = AttackSurface(base_url=start_url)
|
|
260
|
+
|
|
261
|
+
# O(1) dedup mirrors of surface's ordered lists. FormInfo is not
|
|
262
|
+
# hashable, so forms are keyed by a value tuple (page_url included to
|
|
263
|
+
# preserve the original dataclass-__eq__ dedup semantics).
|
|
264
|
+
seen_endpoints: set[str] = set()
|
|
265
|
+
seen_tech: set[str] = set()
|
|
266
|
+
seen_forms: set[tuple[str, str, str, tuple[str, ...]]] = set()
|
|
267
|
+
|
|
268
|
+
disallowed: set[str] = set()
|
|
269
|
+
visited: set[str] = set()
|
|
270
|
+
visited_lock = asyncio.Lock()
|
|
271
|
+
|
|
272
|
+
# Global token-bucket rate limiter shared across all workers.
|
|
273
|
+
last_req_time = 0.0
|
|
274
|
+
rate_lock = asyncio.Lock()
|
|
275
|
+
|
|
276
|
+
# Phase 3: capture root-page HTML for JS analysis.
|
|
277
|
+
root_html: list[str] = []
|
|
278
|
+
|
|
279
|
+
async def _rate_wait() -> None:
|
|
280
|
+
nonlocal last_req_time
|
|
281
|
+
if rate_limit_ms <= 0:
|
|
282
|
+
return
|
|
283
|
+
async with rate_lock:
|
|
284
|
+
now = time.monotonic()
|
|
285
|
+
gap = rate_limit_ms / 1000.0
|
|
286
|
+
to_sleep = last_req_time + gap - now
|
|
287
|
+
if to_sleep > 0:
|
|
288
|
+
await asyncio.sleep(to_sleep)
|
|
289
|
+
last_req_time = time.monotonic()
|
|
290
|
+
|
|
291
|
+
work_queue: asyncio.Queue[tuple[str, int]] = asyncio.Queue()
|
|
292
|
+
|
|
293
|
+
async def _process(url: str, depth: int, client: httpx.AsyncClient) -> None:
|
|
294
|
+
norm = _normalise(url)
|
|
295
|
+
if not norm:
|
|
296
|
+
return
|
|
297
|
+
async with visited_lock:
|
|
298
|
+
if norm in visited:
|
|
299
|
+
return
|
|
300
|
+
if not _in_scope(norm, allowed):
|
|
301
|
+
return
|
|
302
|
+
if _skip_by_extension(norm):
|
|
303
|
+
return
|
|
304
|
+
if self._respect_robots and _is_disallowed(norm, disallowed):
|
|
305
|
+
return
|
|
306
|
+
if surface.pages_visited >= max_pages:
|
|
307
|
+
return
|
|
308
|
+
visited.add(norm)
|
|
309
|
+
|
|
310
|
+
await _rate_wait()
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
resp = await client.get(norm)
|
|
314
|
+
except Exception as exc:
|
|
315
|
+
log.debug("Spider fetch error %s: %s", norm, exc)
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
surface.pages_visited += 1
|
|
319
|
+
|
|
320
|
+
for header in ("server", "x-powered-by", "x-aspnet-version"):
|
|
321
|
+
val = resp.headers.get(header)
|
|
322
|
+
if val and val not in seen_tech:
|
|
323
|
+
seen_tech.add(val)
|
|
324
|
+
surface.technologies.append(val)
|
|
325
|
+
|
|
326
|
+
if _is_dynamic(norm) and norm not in seen_endpoints:
|
|
327
|
+
seen_endpoints.add(norm)
|
|
328
|
+
surface.dynamic_endpoints.append(norm)
|
|
329
|
+
|
|
330
|
+
ct = resp.headers.get("content-type", "")
|
|
331
|
+
if "html" not in ct:
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
html_text = resp.text
|
|
335
|
+
# Capture root page HTML for JS analysis (Phase 3).
|
|
336
|
+
if not root_html and norm == _normalise(start_url):
|
|
337
|
+
root_html.append(html_text)
|
|
338
|
+
|
|
339
|
+
page_parser = _PageParser(norm)
|
|
340
|
+
with contextlib.suppress(Exception):
|
|
341
|
+
page_parser.feed(html_text)
|
|
342
|
+
|
|
343
|
+
for form in page_parser.forms:
|
|
344
|
+
if not form.action:
|
|
345
|
+
continue
|
|
346
|
+
form_key = (form.page_url, form.action, form.method, tuple(form.fields))
|
|
347
|
+
if form_key not in seen_forms:
|
|
348
|
+
seen_forms.add(form_key)
|
|
349
|
+
surface.forms.append(form)
|
|
350
|
+
if form.action not in seen_endpoints:
|
|
351
|
+
seen_endpoints.add(form.action)
|
|
352
|
+
surface.dynamic_endpoints.append(form.action)
|
|
353
|
+
|
|
354
|
+
if depth < max_depth:
|
|
355
|
+
for link in page_parser.links:
|
|
356
|
+
lnorm = _normalise(link)
|
|
357
|
+
if lnorm and _in_scope(lnorm, allowed):
|
|
358
|
+
async with visited_lock:
|
|
359
|
+
if lnorm not in visited:
|
|
360
|
+
work_queue.put_nowait((lnorm, depth + 1))
|
|
361
|
+
|
|
362
|
+
async def _worker(client: httpx.AsyncClient) -> None:
|
|
363
|
+
while True:
|
|
364
|
+
url, depth = await work_queue.get()
|
|
365
|
+
try:
|
|
366
|
+
await _process(url, depth, client)
|
|
367
|
+
except Exception as exc:
|
|
368
|
+
log.debug("Spider worker error on %s: %s", url, exc)
|
|
369
|
+
finally:
|
|
370
|
+
work_queue.task_done()
|
|
371
|
+
|
|
372
|
+
log.info(
|
|
373
|
+
"Spider starting: %s (max_pages=%d depth=%d workers=%d)",
|
|
374
|
+
start_url,
|
|
375
|
+
max_pages,
|
|
376
|
+
max_depth,
|
|
377
|
+
self._workers,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
client_cookies = dict(session_cookies or {})
|
|
381
|
+
|
|
382
|
+
# Block redirects that would leave the authorized scope: an in-scope page
|
|
383
|
+
# 3xx-ing to an internal/metadata host would otherwise be fetched here,
|
|
384
|
+
# outside the agent's scope boundary (SSRF). Same-host redirects are
|
|
385
|
+
# unaffected; an out-of-scope hop raises and the worker skips that page.
|
|
386
|
+
async def _redirect_scope_guard(response: httpx.Response) -> None:
|
|
387
|
+
if response.is_redirect:
|
|
388
|
+
loc = response.headers.get("location")
|
|
389
|
+
if loc and not _in_scope(str(response.url.join(loc)), allowed):
|
|
390
|
+
raise RuntimeError(f"redirect out of scope: {loc}")
|
|
391
|
+
|
|
392
|
+
async with httpx.AsyncClient(
|
|
393
|
+
verify=False,
|
|
394
|
+
timeout=self._timeout,
|
|
395
|
+
follow_redirects=True,
|
|
396
|
+
cookies=client_cookies,
|
|
397
|
+
headers={"User-Agent": "Mozilla/5.0 (compatible; ShadowCat-Spider/2.0)"},
|
|
398
|
+
event_hooks={"response": [_redirect_scope_guard]},
|
|
399
|
+
) as client:
|
|
400
|
+
# Fetch robots.txt first (best-effort).
|
|
401
|
+
if self._respect_robots:
|
|
402
|
+
robots_url = f"{parsed.scheme}://{base_netloc}/robots.txt"
|
|
403
|
+
try:
|
|
404
|
+
resp = await client.get(robots_url)
|
|
405
|
+
if resp.status_code == 200:
|
|
406
|
+
disallowed, extra_paths = _parse_robots(
|
|
407
|
+
resp.text, parsed.scheme, base_netloc
|
|
408
|
+
)
|
|
409
|
+
surface.robots_disallowed = list(disallowed)
|
|
410
|
+
for path in extra_paths:
|
|
411
|
+
work_queue.put_nowait((path, 1))
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
# Seed the queue.
|
|
416
|
+
work_queue.put_nowait((start_url, 0))
|
|
417
|
+
|
|
418
|
+
# Start N workers.
|
|
419
|
+
worker_tasks = [asyncio.create_task(_worker(client)) for _ in range(self._workers)]
|
|
420
|
+
|
|
421
|
+
# Wait until all queued items are processed.
|
|
422
|
+
await work_queue.join()
|
|
423
|
+
|
|
424
|
+
# Stop workers (they are blocked on queue.get()).
|
|
425
|
+
for task in worker_tasks:
|
|
426
|
+
task.cancel()
|
|
427
|
+
await asyncio.gather(*worker_tasks, return_exceptions=True)
|
|
428
|
+
|
|
429
|
+
# Phase 3: JS API analysis on the root page.
|
|
430
|
+
if self._analyze_js and root_html:
|
|
431
|
+
try:
|
|
432
|
+
from backend.crawler.js_analyzer import JsApiExtractor
|
|
433
|
+
|
|
434
|
+
extractor = JsApiExtractor()
|
|
435
|
+
js_paths = await extractor.extract(root_html[0], start_url, client)
|
|
436
|
+
surface.js_api_endpoints = js_paths
|
|
437
|
+
# Promote JS-discovered paths to dynamic_endpoints.
|
|
438
|
+
for path in js_paths:
|
|
439
|
+
abs_url = urllib.parse.urljoin(start_url, path)
|
|
440
|
+
norm = _normalise(abs_url)
|
|
441
|
+
if norm and norm not in seen_endpoints:
|
|
442
|
+
seen_endpoints.add(norm)
|
|
443
|
+
surface.dynamic_endpoints.append(norm)
|
|
444
|
+
except Exception as exc:
|
|
445
|
+
log.debug("JS analysis failed: %s", exc)
|
|
446
|
+
|
|
447
|
+
# Phase 7: headless render of the root for JS-rendered SPAs. The root
|
|
448
|
+
# IS the whole app for a SPA, so one render captures its real DOM and
|
|
449
|
+
# the API endpoints it calls. Optional + best-effort: skipped silently
|
|
450
|
+
# if Playwright (or its browser) isn't installed.
|
|
451
|
+
if self._render_js:
|
|
452
|
+
from backend.crawler import headless
|
|
453
|
+
|
|
454
|
+
if headless.available():
|
|
455
|
+
try:
|
|
456
|
+
rendered = await headless.fetch_rendered(
|
|
457
|
+
start_url,
|
|
458
|
+
timeout_ms=int(self._timeout * 1000) + 15000,
|
|
459
|
+
interact=True, # also click buttons to trigger click-only APIs
|
|
460
|
+
)
|
|
461
|
+
if surface.pages_visited == 0:
|
|
462
|
+
surface.pages_visited = 1
|
|
463
|
+
# Forms/links the JS produced that plain httpx never saw.
|
|
464
|
+
rp = _PageParser(start_url)
|
|
465
|
+
with contextlib.suppress(Exception):
|
|
466
|
+
rp.feed(rendered.html)
|
|
467
|
+
for form in rp.forms:
|
|
468
|
+
key = (form.page_url, form.action, form.method, tuple(form.fields))
|
|
469
|
+
if form.action and key not in seen_forms:
|
|
470
|
+
seen_forms.add(key)
|
|
471
|
+
surface.forms.append(form)
|
|
472
|
+
# The real SPA surface: every API the page called at load.
|
|
473
|
+
for call in rendered.api_calls:
|
|
474
|
+
url_part = call.split(" ", 1)[-1]
|
|
475
|
+
norm = _normalise(url_part)
|
|
476
|
+
if norm and norm not in seen_endpoints:
|
|
477
|
+
seen_endpoints.add(norm)
|
|
478
|
+
surface.dynamic_endpoints.append(norm)
|
|
479
|
+
surface.js_api_endpoints.append(call)
|
|
480
|
+
log.info("Headless render: %d API calls captured", len(rendered.api_calls))
|
|
481
|
+
except Exception as exc:
|
|
482
|
+
log.debug("Headless render failed: %s", exc)
|
|
483
|
+
else:
|
|
484
|
+
log.info("render_js requested but Playwright not available — skipping")
|
|
485
|
+
|
|
486
|
+
log.info(
|
|
487
|
+
"Spider done: %d pages, %d dynamic endpoints, %d forms, %d JS paths",
|
|
488
|
+
surface.pages_visited,
|
|
489
|
+
len(surface.dynamic_endpoints),
|
|
490
|
+
len(surface.forms),
|
|
491
|
+
len(surface.js_api_endpoints),
|
|
492
|
+
)
|
|
493
|
+
return surface
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
# Helpers (unchanged from v1)
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _normalise(url: str) -> str:
|
|
502
|
+
try:
|
|
503
|
+
p = urllib.parse.urlparse(url)
|
|
504
|
+
clean = urllib.parse.urlunparse((p.scheme, p.netloc, p.path, p.params, p.query, ""))
|
|
505
|
+
return clean if clean.startswith("http") else ""
|
|
506
|
+
except Exception:
|
|
507
|
+
return ""
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _in_scope(url: str, allowed: set[str]) -> bool:
|
|
511
|
+
try:
|
|
512
|
+
return urllib.parse.urlparse(url).netloc in allowed
|
|
513
|
+
except Exception:
|
|
514
|
+
return False
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _skip_by_extension(url: str) -> bool:
|
|
518
|
+
path = urllib.parse.urlparse(url).path.lower()
|
|
519
|
+
_, ext = os.path.splitext(path)
|
|
520
|
+
return ext in _SKIP_EXTENSIONS
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _is_dynamic(url: str) -> bool:
|
|
524
|
+
p = urllib.parse.urlparse(url)
|
|
525
|
+
if p.query:
|
|
526
|
+
return True
|
|
527
|
+
return bool(_ID_SEGMENT.search(p.path))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _parse_robots(content: str, scheme: str, netloc: str) -> tuple[set[str], list[str]]:
|
|
531
|
+
disallowed: set[str] = set()
|
|
532
|
+
notable: list[str] = []
|
|
533
|
+
for line in content.splitlines():
|
|
534
|
+
line = line.split("#")[0].strip()
|
|
535
|
+
if line.lower().startswith("disallow:"):
|
|
536
|
+
path = line[len("disallow:") :].strip()
|
|
537
|
+
if path and path != "/":
|
|
538
|
+
disallowed.add(path)
|
|
539
|
+
lower = path.lower()
|
|
540
|
+
if any(
|
|
541
|
+
k in lower
|
|
542
|
+
for k in ("admin", "api", "manage", "secret", "backup", "config", "staff")
|
|
543
|
+
):
|
|
544
|
+
notable.append(f"{scheme}://{netloc}{path}")
|
|
545
|
+
return disallowed, notable
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _is_disallowed(url: str, disallowed: set[str]) -> bool:
|
|
549
|
+
path = urllib.parse.urlparse(url).path
|
|
550
|
+
return any(path.startswith(d) for d in disallowed)
|