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