"""Shared Caido proxy helpers and sandbox-importable ``caido_api`` module.""" from __future__ import annotations import asyncio import json import os import time import urllib.request from typing import TYPE_CHECKING, Any, Literal from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from caido_sdk_client import Client, TokenAuthOptions from caido_sdk_client.types import ( ConnectionInfoInput, CreateScopeOptions, ReplaySendOptions, RequestGetOptions, UpdateScopeOptions, ) if TYPE_CHECKING: from caido_sdk_client import Client as CaidoClient RequestPart = Literal["request", "response"] SortBy = Literal[ "timestamp", "host", "method", "path", "status_code", "response_time", "response_size", "source", ] SortOrder = Literal["asc", "desc"] ScopeAction = Literal["get", "list", "create", "update", "delete"] SitemapDepth = Literal["DIRECT", "ALL"] _SITEMAP_PAGE_SIZE = 30 _DEFAULT_CAIDO_URL = "http://127.0.0.1:48080" _CLIENT_CACHE: dict[str, Client] = {} _REQ_FIELD_MAP: dict[SortBy, tuple[str, str]] = { "timestamp": ("req", "created_at"), "host": ("req", "host"), "method": ("req", "method"), "path": ("req", "path"), "source": ("req", "source"), "status_code": ("resp", "code"), "response_time": ("resp", "roundtrip"), "response_size": ("resp", "length"), } def caido_url() -> str: return os.environ.get("STRIX_CAIDO_URL", _DEFAULT_CAIDO_URL).rstrip("/") def _graphql_url() -> str: base_url = caido_url() parsed = urlparse(base_url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ValueError(f"Invalid Caido URL: {base_url}") return f"{base_url}/graphql" def _login_as_guest() -> str: body = json.dumps({"query": "mutation { loginAsGuest { token { accessToken } } }"}).encode( "utf-8" ) req = urllib.request.Request( # noqa: S310 _graphql_url(), data=body, headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 # nosec B310 payload = json.loads(resp.read()) return str(payload["data"]["loginAsGuest"]["token"]["accessToken"]) async def get_client() -> Client: if client := _CLIENT_CACHE.get("default"): return client token = await asyncio.to_thread(_login_as_guest) client = Client(caido_url(), auth=TokenAuthOptions(token=token)) await client.connect() _CLIENT_CACHE["default"] = client return client async def close_client() -> None: client = _CLIENT_CACHE.pop("default", None) if client is None: return await client.aclose() async def list_requests_with_client( client: CaidoClient, *, httpql_filter: str | None = None, first: int = 50, after: str | None = None, sort_by: SortBy = "timestamp", sort_order: SortOrder = "desc", scope_id: str | None = None, ) -> Any: builder = client.request.list().first(first) if httpql_filter: builder = builder.filter(httpql_filter) if after: builder = builder.after(after) if scope_id: builder = builder.scope(scope_id) target, field = _REQ_FIELD_MAP[sort_by] builder = (builder.descending if sort_order == "desc" else builder.ascending)(target, field) return await builder.execute() async def get_request_with_client( client: CaidoClient, request_id: str, *, part: RequestPart = "request", ) -> Any: # The Caido SDK's generated pydantic model marks Request.raw and # Response.raw as required strings even though the GraphQL fragment # makes them conditional via `@include(if: $includeRequestRaw)`. # Passing False for either causes pydantic validation to fail with # "Field required" on the missing raw field. Always request both — # the caller picks which one to surface via ``part``. opts = RequestGetOptions(request_raw=True, response_raw=True) return await client.request.get(request_id, opts) def build_raw_request( *, method: str, url: str, headers: dict[str, str], body: str, ) -> tuple[ConnectionInfoInput, bytes]: parsed = urlparse(url) if not parsed.scheme or not parsed.netloc: raise ValueError(f"Invalid URL: {url}") is_tls = parsed.scheme.lower() == "https" host = parsed.hostname or "" port = parsed.port or (443 if is_tls else 80) path = parsed.path or "/" if parsed.query: path = f"{path}?{parsed.query}" final_headers = {**headers} final_headers.setdefault("Host", parsed.netloc) final_headers.setdefault("User-Agent", "strix") if body and "Content-Length" not in {k.title() for k in final_headers}: final_headers["Content-Length"] = str(len(body.encode("utf-8"))) lines = [f"{method.upper()} {path} HTTP/1.1"] lines.extend(f"{k}: {v}" for k, v in final_headers.items()) raw = ("\r\n".join(lines) + "\r\n\r\n" + body).encode("utf-8") return ConnectionInfoInput(host=host, port=port, is_tls=is_tls), raw _RESPONSE_BODY_MAX_CHARS = 8192 def parse_raw_response(raw_bytes: bytes | None) -> dict[str, Any] | None: """Parse a raw HTTP response into the same shape ``list_requests`` emits. Returns ``None`` when ``raw_bytes`` is missing or unparseable. On success returns ``{status_code, length, headers, body, body_truncated}`` where ``body`` is decoded as UTF-8 (replacement chars on invalid bytes) and clipped at :data:`_RESPONSE_BODY_MAX_CHARS`. """ if not raw_bytes: return None try: head, _, body_bytes = raw_bytes.partition(b"\r\n\r\n") lines = head.decode("iso-8859-1", errors="replace").split("\r\n") if not lines: return None status_parts = lines[0].split(" ", 2) if len(status_parts) < 2 or not status_parts[1].isdigit(): return None status_code = int(status_parts[1]) headers: dict[str, str] = {} for line in lines[1:]: if ":" not in line: continue k, v = line.split(":", 1) headers[k.strip()] = v.strip() body_text = body_bytes.decode("utf-8", errors="replace") body_truncated = len(body_text) > _RESPONSE_BODY_MAX_CHARS if body_truncated: body_text = body_text[:_RESPONSE_BODY_MAX_CHARS] return { "status_code": status_code, "length": len(body_bytes), "headers": headers, "body": body_text, "body_truncated": body_truncated, } except Exception: # noqa: BLE001 - tolerate any malformed raw bytes; None signals "unparseable" to the caller. return None def parse_raw_request(raw_content: str) -> dict[str, Any]: lines = raw_content.split("\n") request_line = lines[0].strip().split(" ") if len(request_line) < 2: raise ValueError("Invalid request line format") method, url_path = request_line[0], request_line[1] parsed_headers: dict[str, str] = {} body_start = 0 for i, line in enumerate(lines[1:], 1): if line.strip() == "": body_start = i + 1 break if ":" in line: key, value = line.split(":", 1) parsed_headers[key.strip()] = value.strip() body = "\n".join(lines[body_start:]).strip() if body_start < len(lines) else "" return {"method": method, "url_path": url_path, "headers": parsed_headers, "body": body} def full_url_from_components( original: Any, components: dict[str, Any], modifications: dict[str, Any], ) -> str: if "url" in modifications: return str(modifications["url"]) headers = components["headers"] host_header = headers.get("Host") or original.host scheme = "https" if original.is_tls else "http" return f"{scheme}://{host_header}{components['url_path']}" def apply_modifications( components: dict[str, Any], modifications: dict[str, Any], full_url: str, ) -> dict[str, Any]: headers = dict(components["headers"]) body = components["body"] final_url = full_url if "params" in modifications: parsed = urlparse(final_url) existing = {k: v[0] if v else "" for k, v in parse_qs(parsed.query).items()} existing.update(modifications["params"]) final_url = urlunparse(parsed._replace(query=urlencode(existing))) if "headers" in modifications: headers.update(modifications["headers"]) if "body" in modifications: body = modifications["body"] if "cookies" in modifications: cookies: dict[str, str] = {} if headers.get("Cookie"): for cookie in headers["Cookie"].split(";"): if "=" in cookie: k, v = cookie.split("=", 1) cookies[k.strip()] = v.strip() cookies.update(modifications["cookies"]) headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in cookies.items()) return { "method": components["method"], "url": final_url, "headers": headers, "body": body, } _REPLAY_SEND_TIMEOUT_SECONDS = 30.0 async def replay_send_raw( client: CaidoClient, *, raw: bytes, connection: ConnectionInfoInput, ) -> dict[str, Any]: started = time.time() # Create an empty replay session, then dispatch via ``send()``. # Passing ``CreateReplaySessionFromRaw`` here would also seed a stored # entry on the server side, leading the caller to observe two history # rows per call (one without response from the create-step seed, one # with response from the actual send). The empty-create + send flow # produces exactly one dispatched request. session = await client.replay.sessions.create() try: result = await asyncio.wait_for( client.replay.send( session.id, ReplaySendOptions(raw=raw, connection=connection), ), timeout=_REPLAY_SEND_TIMEOUT_SECONDS, ) except TimeoutError: elapsed_ms = int((time.time() - started) * 1000) return { "session_id": str(session.id), "status": "ERROR", "error": ( f"Caido replay dispatch did not complete within " f"{_REPLAY_SEND_TIMEOUT_SECONDS:.0f}s — the target may be " "unroutable from the sandbox, or Caido's outbound HTTP client " "is stalled; check the target host/port and retry" ), "elapsed_ms": elapsed_ms, "response_raw": None, } elapsed_ms = int((time.time() - started) * 1000) response = getattr(result.entry, "response", None) response_raw = getattr(response, "raw", None) if response is not None else None return { "session_id": str(session.id), "status": result.status, "error": result.error, "elapsed_ms": elapsed_ms, "response_raw": response_raw, } async def scope_list(client: CaidoClient) -> Any: return await client.scope.list() async def scope_get(client: CaidoClient, scope_id: str) -> Any: return await client.scope.get(scope_id) async def scope_create( client: CaidoClient, *, name: str, allowlist: list[str] | None = None, denylist: list[str] | None = None, ) -> Any: return await client.scope.create( CreateScopeOptions( name=name, allowlist=list(allowlist or []), denylist=list(denylist or []), ), ) async def scope_update( client: CaidoClient, scope_id: str, *, name: str, allowlist: list[str] | None = None, denylist: list[str] | None = None, ) -> Any: return await client.scope.update( scope_id, UpdateScopeOptions( name=name, allowlist=list(allowlist or []), denylist=list(denylist or []), ), ) async def scope_delete(client: CaidoClient, scope_id: str) -> None: await client.scope.delete(scope_id) async def list_requests( *, httpql_filter: str | None = None, first: int = 50, after: str | None = None, sort_by: SortBy = "timestamp", sort_order: SortOrder = "desc", scope_id: str | None = None, ) -> Any: return await list_requests_with_client( await get_client(), httpql_filter=httpql_filter, first=first, after=after, sort_by=sort_by, sort_order=sort_order, scope_id=scope_id, ) async def view_request(request_id: str, *, part: RequestPart = "request") -> Any: return await get_request_with_client(await get_client(), request_id, part=part) async def repeat_request( request_id: str, *, modifications: dict[str, Any] | None = None, ) -> dict[str, Any]: mods = modifications or {} result = await get_request_with_client(await get_client(), request_id, part="request") if result is None or result.request.raw is None: raise ValueError(f"Request {request_id} not found") original = result.request raw_str = result.request.raw.decode("utf-8", errors="replace") components = parse_raw_request(raw_str) full_url = full_url_from_components(original, components, mods) modified = apply_modifications(components, mods, full_url) connection, raw = build_raw_request( method=modified["method"], url=modified["url"], headers=modified["headers"], body=modified["body"], ) return await replay_send_raw(await get_client(), raw=raw, connection=connection) async def scope_rules( action: ScopeAction, *, allowlist: list[str] | None = None, denylist: list[str] | None = None, scope_id: str | None = None, scope_name: str | None = None, ) -> Any: client = await get_client() if action == "list": result = await scope_list(client) elif action == "get": if not scope_id: raise ValueError("scope_id required for get") result = await scope_get(client, scope_id) elif action == "create": if not scope_name: raise ValueError("scope_name required for create") result = await scope_create( client, name=scope_name, allowlist=allowlist, denylist=denylist, ) elif action == "update": if not scope_id or not scope_name: raise ValueError("scope_id and scope_name required for update") result = await scope_update( client, scope_id, name=scope_name, allowlist=allowlist, denylist=denylist, ) elif action == "delete": if not scope_id: raise ValueError("scope_id required for delete") await scope_delete(client, scope_id) result = {"deleted": scope_id} else: raise ValueError(f"Unknown action: {action}") return result _SITEMAP_ROOTS_QUERY = """ query GetSitemapRoots($scopeId: ID) { sitemapRootEntries(scopeId: $scopeId) { edges { node { id kind label hasDescendants metadata { ... on SitemapEntryMetadataDomain { isTls port } } request { method path response { statusCode } } } } count { value } } } """ _SITEMAP_DESCENDANTS_QUERY = """ query GetSitemapDescendants($parentId: ID!, $depth: SitemapDescendantsDepth!) { sitemapDescendantEntries(parentId: $parentId, depth: $depth) { edges { node { id kind label hasDescendants request { method path response { statusCode } } } } count { value } } } """ _SITEMAP_ENTRY_QUERY = """ query GetSitemapEntry($id: ID!) { sitemapEntry(id: $id) { id kind label hasDescendants metadata { ... on SitemapEntryMetadataDomain { isTls port } } request { method path response { statusCode length roundtripTime } } requests(first: 30, order: {by: CREATED_AT, ordering: DESC}) { edges { node { method path response { statusCode length } } } count { value } } } } """ def _clean_sitemap_metadata(node: dict[str, Any]) -> dict[str, Any]: cleaned: dict[str, Any] = { "id": node["id"], "kind": node["kind"], "label": node["label"], "has_descendants": node["hasDescendants"], } meta = node.get("metadata") if isinstance(meta, dict) and (meta.get("isTls") is not None or meta.get("port")): meta_out: dict[str, Any] = {} if meta.get("isTls") is not None: meta_out["is_tls"] = meta["isTls"] if meta.get("port"): meta_out["port"] = meta["port"] cleaned["metadata"] = meta_out return cleaned def _clean_sitemap_request_summary(req: dict[str, Any] | None) -> dict[str, Any] | None: """Same field names as ``list_requests`` emits for a request_summary.""" if not req: return None out: dict[str, Any] = {} if req.get("method"): out["method"] = req["method"] if req.get("path"): out["path"] = req["path"] resp = req.get("response") or {} if resp.get("statusCode"): out["status_code"] = resp["statusCode"] return out or None def _clean_sitemap_response(resp: dict[str, Any]) -> dict[str, Any]: """Same field names as ``list_requests`` emits for a response_summary.""" out: dict[str, Any] = {} if resp.get("statusCode"): out["status_code"] = resp["statusCode"] if resp.get("length"): out["length"] = resp["length"] if resp.get("roundtripTime"): out["roundtrip_ms"] = resp["roundtripTime"] return out async def list_sitemap_with_client( client: CaidoClient, *, scope_id: str | None = None, parent_id: str | None = None, depth: SitemapDepth = "DIRECT", page: int = 1, page_size: int = _SITEMAP_PAGE_SIZE, ) -> dict[str, Any]: """Browse Caido's discovered sitemap. The Caido GraphQL ``sitemap*Entries`` operations don't support native pagination, so we fetch all edges for the requested level and slice client-side. """ if parent_id: raw = await client.graphql.query( _SITEMAP_DESCENDANTS_QUERY, variables={"parentId": parent_id, "depth": depth}, ) data = raw.get("sitemapDescendantEntries") or {} else: raw = await client.graphql.query( _SITEMAP_ROOTS_QUERY, variables={"scopeId": scope_id}, ) data = raw.get("sitemapRootEntries") or {} edges = data.get("edges") or [] total = (data.get("count") or {}).get("value", 0) skip = max(0, (page - 1) * page_size) sliced = [edge["node"] for edge in edges[skip : skip + page_size]] cleaned: list[dict[str, Any]] = [] for node in sliced: entry = _clean_sitemap_metadata(node) summary = _clean_sitemap_request_summary(node.get("request")) if summary: entry["request"] = summary cleaned.append(entry) total_pages = (total + page_size - 1) // page_size if total else 0 return { "success": True, "entries": cleaned, "page": page, "page_size": page_size, "total_pages": total_pages, "total_count": total, "has_more": page < total_pages, } async def view_sitemap_entry_with_client( client: CaidoClient, entry_id: str, ) -> dict[str, Any]: raw = await client.graphql.query(_SITEMAP_ENTRY_QUERY, variables={"id": entry_id}) entry = raw.get("sitemapEntry") if not entry: return {"success": False, "error": f"Sitemap entry {entry_id} not found"} cleaned = _clean_sitemap_metadata(entry) primary = entry.get("request") or {} if primary: primary_clean: dict[str, Any] = {} if primary.get("method"): primary_clean["method"] = primary["method"] if primary.get("path"): primary_clean["path"] = primary["path"] if primary.get("response"): primary_clean["response"] = _clean_sitemap_response(primary["response"]) if primary_clean: cleaned["request"] = primary_clean related = entry.get("requests") or {} related_edges = related.get("edges") or [] related_nodes = [edge["node"] for edge in related_edges] related_clean = [ summary for summary in (_clean_sitemap_request_summary(n) for n in related_nodes) if summary is not None ] cleaned["related_requests"] = { "requests": related_clean, "total_count": (related.get("count") or {}).get("value", 0), } return {"success": True, "entry": cleaned} async def list_sitemap( *, scope_id: str | None = None, parent_id: str | None = None, depth: SitemapDepth = "DIRECT", page: int = 1, page_size: int = _SITEMAP_PAGE_SIZE, ) -> dict[str, Any]: return await list_sitemap_with_client( await get_client(), scope_id=scope_id, parent_id=parent_id, depth=depth, page=page, page_size=page_size, ) async def view_sitemap_entry(entry_id: str) -> dict[str, Any]: return await view_sitemap_entry_with_client(await get_client(), entry_id) __all__ = [ "RequestPart", "ScopeAction", "SitemapDepth", "SortBy", "SortOrder", "close_client", "get_client", "list_requests", "list_sitemap", "repeat_request", "scope_rules", "view_request", "view_sitemap_entry", ]