diff --git a/containers/docker-entrypoint.sh b/containers/docker-entrypoint.sh index daec2fb..53c8db3 100644 --- a/containers/docker-entrypoint.sh +++ b/containers/docker-entrypoint.sh @@ -47,68 +47,7 @@ fi sleep 2 -echo "Fetching API token..." -TOKEN="" -for attempt in 1 2 3 4 5; do - RESPONSE=$(curl -sL -X POST \ - -H "Content-Type: application/json" \ - -d '{"query":"mutation LoginAsGuest { loginAsGuest { token { accessToken } } }"}' \ - http://localhost:${CAIDO_PORT}/graphql) - - TOKEN=$(echo "$RESPONSE" | jq -r '.data.loginAsGuest.token.accessToken // empty') - - if [ -n "$TOKEN" ] && [ "$TOKEN" != "null" ]; then - echo "Successfully obtained API token (attempt $attempt)." - break - fi - - echo "Token fetch attempt $attempt failed: $RESPONSE" - sleep $((attempt * 2)) -done - -if [ -z "$TOKEN" ] || [ "$TOKEN" == "null" ]; then - echo "ERROR: Failed to get API token from Caido after 5 attempts." - echo "=== Caido log ===" - cat "$CAIDO_LOG" 2>/dev/null || echo "(no log available)" - exit 1 -fi - -export CAIDO_API_TOKEN=$TOKEN -echo "Caido API token has been set." - -echo "Creating a new Caido project..." -CREATE_PROJECT_RESPONSE=$(curl -sL -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{"query":"mutation CreateProject { createProject(input: {name: \"sandbox\", temporary: true}) { project { id } } }"}' \ - http://localhost:${CAIDO_PORT}/graphql) - -PROJECT_ID=$(echo $CREATE_PROJECT_RESPONSE | jq -r '.data.createProject.project.id') - -if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" == "null" ]; then - echo "Failed to create Caido project." - echo "Response: $CREATE_PROJECT_RESPONSE" - exit 1 -fi - -echo "Caido project created with ID: $PROJECT_ID" - -echo "Selecting Caido project..." -SELECT_RESPONSE=$(curl -sL -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{"query":"mutation SelectProject { selectProject(id: \"'$PROJECT_ID'\") { currentProject { project { id } } } }"}' \ - http://localhost:${CAIDO_PORT}/graphql) - -SELECTED_ID=$(echo $SELECT_RESPONSE | jq -r '.data.selectProject.currentProject.project.id') - -if [ "$SELECTED_ID" != "$PROJECT_ID" ]; then - echo "Failed to select Caido project." - echo "Response: $SELECT_RESPONSE" - exit 1 -fi - -echo "✅ Caido project selected successfully." +echo "Caido is up — host bootstraps the guest token + project via the Python SDK." echo "Configuring system-wide proxy settings..." @@ -120,7 +59,6 @@ export HTTPS_PROXY=http://127.0.0.1:${CAIDO_PORT} export ALL_PROXY=http://127.0.0.1:${CAIDO_PORT} export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt -export CAIDO_API_TOKEN=${TOKEN} EOF cat << EOF | sudo tee /etc/environment @@ -129,7 +67,6 @@ https_proxy=http://127.0.0.1:${CAIDO_PORT} HTTP_PROXY=http://127.0.0.1:${CAIDO_PORT} HTTPS_PROXY=http://127.0.0.1:${CAIDO_PORT} ALL_PROXY=http://127.0.0.1:${CAIDO_PORT} -CAIDO_API_TOKEN=${TOKEN} EOF cat << EOF | sudo tee /etc/wgetrc diff --git a/pyproject.toml b/pyproject.toml index b01a853..4a13ca4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ dependencies = [ "requests>=2.32.0", "cvss>=3.2", "scrubadub>=2.0.1", + "caido-sdk-client>=0.2.0", + "aiohttp>=3.10.0", ] [project.scripts] @@ -53,7 +55,6 @@ sandbox = [ "ipython>=9.3.0", "openhands-aci>=0.3.0", "playwright>=1.48.0", - "gql[requests]>=3.5.3", "libtmux>=0.46.2", ] @@ -120,8 +121,11 @@ module = [ "cvss.*", "scrubadub.*", "docker.*", + "caido_sdk_client.*", + "aiohttp.*", ] ignore_missing_imports = true +disable_error_code = ["import-untyped"] # ============================================================================ # Ruff Configuration (Fast Python Linter & Formatter) @@ -246,7 +250,7 @@ ignore = [ "strix/tools/browser/tool.py" = ["TC002"] "strix/tools/terminal/tool.py" = ["TC002"] "strix/tools/python/tool.py" = ["TC002"] -"strix/tools/proxy/tools.py" = ["TC002"] +"strix/tools/proxy/tools.py" = ["TC002", "PLR0911"] "strix/tools/agents_graph/tools.py" = ["TC002"] "strix/agents/factory.py" = ["TC002"] # Entry point: ``Path`` is used at runtime by the typing of the diff --git a/strix/agents/factory.py b/strix/agents/factory.py index 521e349..d390830 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -50,12 +50,10 @@ from strix.tools.notes.tools import ( ) from strix.tools.proxy.tools import ( list_requests, - list_sitemap, repeat_request, scope_rules, send_request, view_request, - view_sitemap_entry, ) from strix.tools.python.tool import python_action from strix.tools.reporting.tool import create_vulnerability_report @@ -112,8 +110,6 @@ _BASE_TOOLS: tuple[Tool, ...] = ( send_request, repeat_request, scope_rules, - list_sitemap, - view_sitemap_entry, # Multi-agent graph tools (the bus is in ctx.context) view_agent_graph, agent_status, diff --git a/strix/entry.py b/strix/entry.py index 903d131..88c6b2d 100644 --- a/strix/entry.py +++ b/strix/entry.py @@ -34,6 +34,7 @@ from strix.run_config_factory import ( make_run_config, ) from strix.sandbox import session_manager +from strix.sandbox.caido_bootstrap import bootstrap_caido_client from strix.sandbox.healthcheck import wait_for_http_ready, wait_for_tcp_ready @@ -221,6 +222,9 @@ async def run_strix_scan( ), ) + caido_client = await bootstrap_caido_client(int(bundle["caido_host_port"])) + bundle["caido_client"] = caido_client + try: scan_mode = str(scan_config.get("scan_mode") or "deep") is_whitebox = bool(scan_config.get("is_whitebox", False)) @@ -256,6 +260,7 @@ async def run_strix_scan( sandbox_token=bundle["bearer"], tool_server_host_port=bundle["tool_server_host_port"], caido_host_port=bundle["caido_host_port"], + caido_client=caido_client, agent_id=root_id, parent_id=None, tracer=tracer, diff --git a/strix/run_config_factory.py b/strix/run_config_factory.py index 5c059c9..a1af849 100644 --- a/strix/run_config_factory.py +++ b/strix/run_config_factory.py @@ -126,6 +126,7 @@ def make_agent_context( run_id: str | None = None, sandbox_client: Any | None = None, agent_factory: Any | None = None, + caido_client: Any | None = None, ) -> dict[str, Any]: """Build the per-agent ``context`` dict passed to ``Runner.run(context=...)``. @@ -145,6 +146,7 @@ def make_agent_context( "sandbox_token": sandbox_token, "tool_server_host_port": tool_server_host_port, "caido_host_port": caido_host_port, + "caido_client": caido_client, "agent_id": agent_id, "parent_id": parent_id, "tracer": tracer, diff --git a/strix/sandbox/caido_bootstrap.py b/strix/sandbox/caido_bootstrap.py new file mode 100644 index 0000000..7e41d6a --- /dev/null +++ b/strix/sandbox/caido_bootstrap.py @@ -0,0 +1,84 @@ +"""Caido client bootstrap. + +Caido CLI runs as an in-container sidecar. We connect from the host to +its mapped port, fetch a guest token (the CLI runs with +``--allow-guests``), then create + select a temporary project so the +SDK has a project context to operate on. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import aiohttp +from caido_sdk_client import Client, TokenAuthOptions +from caido_sdk_client.types import CreateProjectOptions + + +logger = logging.getLogger(__name__) + + +_LOGIN_AS_GUEST_QUERY = "mutation LoginAsGuest { loginAsGuest { token { accessToken } } }" + + +async def _login_as_guest(url: str, *, attempts: int = 5) -> str: + """POST ``loginAsGuest`` mutation; return the access token. + + Retries up to ``attempts`` times with exponential-ish backoff, mirroring + what the legacy bash entrypoint did. The Caido sidecar may not be ready + on the first poke even after its TCP port accepts connections. + """ + last_err: Exception | None = None + async with aiohttp.ClientSession() as session: + for i in range(1, attempts + 1): + try: + async with session.post( + f"{url}/graphql", + json={"query": _LOGIN_AS_GUEST_QUERY}, + headers={"Content-Type": "application/json"}, + timeout=aiohttp.ClientTimeout(total=15), + ) as response: + response.raise_for_status() + payload: dict[str, Any] = await response.json() + token = ( + payload.get("data", {}) + .get("loginAsGuest", {}) + .get("token", {}) + .get("accessToken") + ) + if token: + return str(token) + last_err = RuntimeError(f"loginAsGuest returned no token: {payload}") + except (aiohttp.ClientError, TimeoutError, RuntimeError) as exc: + last_err = exc + logger.debug("loginAsGuest attempt %d/%d failed: %s", i, attempts, exc) + await asyncio.sleep(min(2.0 * i, 8.0)) + + raise RuntimeError(f"loginAsGuest failed after {attempts} attempts: {last_err}") + + +async def bootstrap_caido_client(host_port: int) -> Client: + """Connect to the in-container Caido sidecar and select a fresh project. + + Args: + host_port: Resolved host port that maps to the container's Caido + GraphQL listener. + + Returns: + A connected :class:`caido_sdk_client.Client` ready to use. + """ + url = f"http://127.0.0.1:{host_port}" + logger.info("Bootstrapping Caido client at %s", url) + + access_token = await _login_as_guest(url) + client = Client(url, auth=TokenAuthOptions(token=access_token)) + await client.connect() + + project = await client.project.create( + CreateProjectOptions(name="sandbox", temporary=True), + ) + await client.project.select(project.id) + logger.info("Caido project selected: %s", project.id) + return client diff --git a/strix/sandbox/session_manager.py b/strix/sandbox/session_manager.py index 9b0d924..270b633 100644 --- a/strix/sandbox/session_manager.py +++ b/strix/sandbox/session_manager.py @@ -105,7 +105,6 @@ async def create_or_reuse( value={ "TOOL_SERVER_TOKEN": bearer, "TOOL_SERVER_PORT": str(_CONTAINER_TOOL_SERVER_PORT), - "CAIDO_PORT": str(_CONTAINER_CAIDO_PORT), "STRIX_SANDBOX_EXECUTION_TIMEOUT": str(execution_timeout), "PYTHONUNBUFFERED": "1", "HOST_GATEWAY": "host.docker.internal", @@ -162,6 +161,13 @@ async def cleanup(scan_id: str) -> None: logger.debug("cleanup(%s): no cached session", scan_id) return + caido_client = bundle.get("caido_client") + if caido_client is not None: + try: + await caido_client.aclose() + except Exception: # noqa: BLE001 + logger.debug("cleanup(%s): caido_client.aclose() raised", scan_id, exc_info=True) + try: await bundle["client"].delete(bundle["session"]) logger.info("Cleaned up sandbox session for scan %s", scan_id) diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index 7ac938f..f8ab3f5 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -406,6 +406,7 @@ async def create_agent( sandbox_token=inner.get("sandbox_token"), tool_server_host_port=inner.get("tool_server_host_port"), caido_host_port=inner.get("caido_host_port"), + caido_client=inner.get("caido_client"), agent_id=child_id, parent_id=parent_id, tracer=inner.get("tracer"), diff --git a/strix/tools/proxy/__init__.py b/strix/tools/proxy/__init__.py index 8785288..4a4b324 100644 --- a/strix/tools/proxy/__init__.py +++ b/strix/tools/proxy/__init__.py @@ -1,20 +1,10 @@ -from .proxy_actions import ( - list_requests, - list_sitemap, - repeat_request, - scope_rules, - send_request, - view_request, - view_sitemap_entry, -) +from .tools import list_requests, repeat_request, scope_rules, send_request, view_request __all__ = [ "list_requests", - "list_sitemap", "repeat_request", "scope_rules", "send_request", "view_request", - "view_sitemap_entry", ] diff --git a/strix/tools/proxy/proxy_actions.py b/strix/tools/proxy/proxy_actions.py deleted file mode 100644 index 0a3201c..0000000 --- a/strix/tools/proxy/proxy_actions.py +++ /dev/null @@ -1,113 +0,0 @@ -from typing import Any, Literal - -from strix.tools.registry import register_tool - - -RequestPart = Literal["request", "response"] - - -@register_tool -def list_requests( - httpql_filter: str | None = None, - start_page: int = 1, - end_page: int = 1, - page_size: int = 50, - sort_by: Literal[ - "timestamp", - "host", - "method", - "path", - "status_code", - "response_time", - "response_size", - "source", - ] = "timestamp", - sort_order: Literal["asc", "desc"] = "desc", - scope_id: str | None = None, -) -> dict[str, Any]: - from .proxy_manager import get_proxy_manager - - manager = get_proxy_manager() - return manager.list_requests( - httpql_filter, start_page, end_page, page_size, sort_by, sort_order, scope_id - ) - - -@register_tool -def view_request( - request_id: str, - part: RequestPart = "request", - search_pattern: str | None = None, - page: int = 1, - page_size: int = 50, -) -> dict[str, Any]: - from .proxy_manager import get_proxy_manager - - manager = get_proxy_manager() - return manager.view_request(request_id, part, search_pattern, page, page_size) - - -@register_tool -def send_request( - method: str, - url: str, - headers: dict[str, str] | None = None, - body: str = "", - timeout: int = 30, -) -> dict[str, Any]: - from .proxy_manager import get_proxy_manager - - if headers is None: - headers = {} - manager = get_proxy_manager() - return manager.send_simple_request(method, url, headers, body, timeout) - - -@register_tool -def repeat_request( - request_id: str, - modifications: dict[str, Any] | None = None, -) -> dict[str, Any]: - from .proxy_manager import get_proxy_manager - - if modifications is None: - modifications = {} - manager = get_proxy_manager() - return manager.repeat_request(request_id, modifications) - - -@register_tool -def scope_rules( - action: Literal["get", "list", "create", "update", "delete"], - allowlist: list[str] | None = None, - denylist: list[str] | None = None, - scope_id: str | None = None, - scope_name: str | None = None, -) -> dict[str, Any]: - from .proxy_manager import get_proxy_manager - - manager = get_proxy_manager() - return manager.scope_rules(action, allowlist, denylist, scope_id, scope_name) - - -@register_tool -def list_sitemap( - scope_id: str | None = None, - parent_id: str | None = None, - depth: Literal["DIRECT", "ALL"] = "DIRECT", - page: int = 1, -) -> dict[str, Any]: - from .proxy_manager import get_proxy_manager - - manager = get_proxy_manager() - return manager.list_sitemap(scope_id, parent_id, depth, page) - - -@register_tool -def view_sitemap_entry( - entry_id: str, -) -> dict[str, Any]: - from .proxy_manager import get_proxy_manager - - manager = get_proxy_manager() - return manager.view_sitemap_entry(entry_id) diff --git a/strix/tools/proxy/proxy_manager.py b/strix/tools/proxy/proxy_manager.py deleted file mode 100644 index c028e6c..0000000 --- a/strix/tools/proxy/proxy_manager.py +++ /dev/null @@ -1,797 +0,0 @@ -import base64 -import os -import re -import time -from typing import TYPE_CHECKING, Any -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse - -import requests -from gql import Client, gql -from gql.transport.exceptions import TransportQueryError -from gql.transport.requests import RequestsHTTPTransport -from requests.exceptions import ProxyError, RequestException, Timeout - - -if TYPE_CHECKING: - from collections.abc import Callable - - -CAIDO_PORT = 48080 # Fixed port inside container - - -class ProxyManager: - def __init__(self, auth_token: str | None = None): - host = "127.0.0.1" - self.base_url = f"http://{host}:{CAIDO_PORT}/graphql" - self.proxies = { - "http": f"http://{host}:{CAIDO_PORT}", - "https": f"http://{host}:{CAIDO_PORT}", - } - self.auth_token = auth_token or os.getenv("CAIDO_API_TOKEN") - - def _get_client(self) -> Client: - transport = RequestsHTTPTransport( - url=self.base_url, headers={"Authorization": f"Bearer {self.auth_token}"} - ) - return Client(transport=transport, fetch_schema_from_transport=False) - - def list_requests( - self, - httpql_filter: str | None = None, - start_page: int = 1, - end_page: int = 1, - page_size: int = 50, - sort_by: str = "timestamp", - sort_order: str = "desc", - scope_id: str | None = None, - ) -> dict[str, Any]: - offset = (start_page - 1) * page_size - limit = (end_page - start_page + 1) * page_size - - sort_mapping = { - "timestamp": "CREATED_AT", - "host": "HOST", - "method": "METHOD", - "path": "PATH", - "status_code": "RESP_STATUS_CODE", - "response_time": "RESP_ROUNDTRIP_TIME", - "response_size": "RESP_LENGTH", - "source": "SOURCE", - } - - query = gql(""" - query GetRequests( - $limit: Int, $offset: Int, $filter: HTTPQL, - $order: RequestResponseOrderInput, $scopeId: ID - ) { - requestsByOffset( - limit: $limit, offset: $offset, filter: $filter, - order: $order, scopeId: $scopeId - ) { - edges { - node { - id method host path query createdAt length isTls port - source alteration fileExtension - response { id statusCode length roundtripTime createdAt } - } - } - count { value } - } - } - """) - - variables = { - "limit": limit, - "offset": offset, - "filter": httpql_filter, - "order": { - "by": sort_mapping.get(sort_by, "CREATED_AT"), - "ordering": sort_order.upper(), - }, - "scopeId": scope_id, - } - - try: - result = self._get_client().execute(query, variable_values=variables) - data = result.get("requestsByOffset", {}) - nodes = [edge["node"] for edge in data.get("edges", [])] - - count_data = data.get("count") or {} - return { - "requests": nodes, - "total_count": count_data.get("value", 0), - "start_page": start_page, - "end_page": end_page, - "page_size": page_size, - "offset": offset, - "returned_count": len(nodes), - "sort_by": sort_by, - "sort_order": sort_order, - } - except (TransportQueryError, ValueError, KeyError) as e: - return {"requests": [], "total_count": 0, "error": f"Error fetching requests: {e}"} - - def view_request( - self, - request_id: str, - part: str = "request", - search_pattern: str | None = None, - page: int = 1, - page_size: int = 50, - ) -> dict[str, Any]: - queries = { - "request": """query GetRequest($id: ID!) { - request(id: $id) { - id method host path query createdAt length isTls port - source alteration edited raw - } - }""", - "response": """query GetRequest($id: ID!) { - request(id: $id) { - id response { - id statusCode length roundtripTime createdAt raw - } - } - }""", - } - - if part not in queries: - return {"error": f"Invalid part '{part}'. Use 'request' or 'response'"} - - try: - result = self._get_client().execute( - gql(queries[part]), variable_values={"id": request_id} - ) - request_data = result.get("request", {}) - - if not request_data: - return {"error": f"Request {request_id} not found"} - - if part == "request": - raw_content = request_data.get("raw") - else: - response_data = request_data.get("response") or {} - raw_content = response_data.get("raw") - - if not raw_content: - return {"error": "No content available"} - - content = base64.b64decode(raw_content).decode("utf-8", errors="replace") - - if part == "response": - request_data["response"]["raw"] = content - else: - request_data["raw"] = content - - return ( - self._search_content(request_data, content, search_pattern) - if search_pattern - else self._paginate_content(request_data, content, page, page_size) - ) - - except (TransportQueryError, ValueError, KeyError, UnicodeDecodeError) as e: - return {"error": f"Failed to view request: {e}"} - - def _search_content( - self, request_data: dict[str, Any], content: str, pattern: str - ) -> dict[str, Any]: - try: - regex = re.compile(pattern, re.IGNORECASE | re.MULTILINE | re.DOTALL) - matches = [] - - for match in regex.finditer(content): - start, end = match.start(), match.end() - context_size = 120 - - before = re.sub(r"\s+", " ", content[max(0, start - context_size) : start].strip())[ - -100: - ] - after = re.sub(r"\s+", " ", content[end : end + context_size].strip())[:100] - - matches.append( - {"match": match.group(), "before": before, "after": after, "position": start} - ) - - if len(matches) >= 20: - break - - return { - "id": request_data.get("id"), - "matches": matches, - "total_matches": len(matches), - "search_pattern": pattern, - "truncated": len(matches) >= 20, - } - except re.error as e: - return {"error": f"Invalid regex: {e}"} - - def _paginate_content( - self, request_data: dict[str, Any], content: str, page: int, page_size: int - ) -> dict[str, Any]: - display_lines = [] - for line in content.split("\n"): - if len(line) <= 80: - display_lines.append(line) - else: - display_lines.extend( - [ - line[i : i + 80] + (" \\" if i + 80 < len(line) else "") - for i in range(0, len(line), 80) - ] - ) - - total_lines = len(display_lines) - total_pages = (total_lines + page_size - 1) // page_size - page = max(1, min(page, total_pages)) - - start_line = (page - 1) * page_size - end_line = min(total_lines, start_line + page_size) - - return { - "id": request_data.get("id"), - "content": "\n".join(display_lines[start_line:end_line]), - "page": page, - "total_pages": total_pages, - "showing_lines": f"{start_line + 1}-{end_line} of {total_lines}", - "has_more": page < total_pages, - } - - def send_simple_request( - self, - method: str, - url: str, - headers: dict[str, str] | None = None, - body: str = "", - timeout: int = 30, - ) -> dict[str, Any]: - if headers is None: - headers = {} - try: - start_time = time.time() - response = requests.request( - method=method, - url=url, - headers=headers, - data=body or None, - proxies=self.proxies, - timeout=timeout, - verify=False, - ) - response_time = int((time.time() - start_time) * 1000) - - body_content = response.text - if len(body_content) > 10000: - body_content = body_content[:10000] + "\n... [truncated]" - - return { - "status_code": response.status_code, - "headers": dict(response.headers), - "body": body_content, - "response_time_ms": response_time, - "url": response.url, - "message": ( - "Request sent through proxy - check list_requests() for captured traffic" - ), - } - except (RequestException, ProxyError, Timeout) as e: - return {"error": f"Request failed: {type(e).__name__}", "details": str(e), "url": url} - - def repeat_request( - self, request_id: str, modifications: dict[str, Any] | None = None - ) -> dict[str, Any]: - if modifications is None: - modifications = {} - - original = self.view_request(request_id, "request") - if "error" in original: - return {"error": f"Could not retrieve original request: {original['error']}"} - - raw_content = original.get("content", "") - if not raw_content: - return {"error": "No raw request content found"} - - request_components = self._parse_http_request(raw_content) - if "error" in request_components: - return request_components - - full_url = self._build_full_url(request_components, modifications) - if "error" in full_url: - return full_url - - modified_request = self._apply_modifications( - request_components, modifications, full_url["url"] - ) - - return self._send_modified_request(modified_request, request_id, modifications) - - def _parse_http_request(self, raw_content: str) -> dict[str, Any]: - lines = raw_content.split("\n") - request_line = lines[0].strip().split(" ") - if len(request_line) < 2: - return {"error": "Invalid request line format"} - - method, url_path = request_line[0], request_line[1] - - headers = {} - 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) - 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": headers, "body": body} - - def _build_full_url( - self, components: dict[str, Any], modifications: dict[str, Any] - ) -> dict[str, Any]: - headers = components["headers"] - host = headers.get("Host", "") - if not host: - return {"error": "No Host header found"} - - protocol = ( - "https" if ":443" in host or "https" in headers.get("Referer", "").lower() else "http" - ) - full_url = f"{protocol}://{host}{components['url_path']}" - - if "url" in modifications: - full_url = modifications["url"] - - return {"url": full_url} - - def _apply_modifications( - self, components: dict[str, Any], modifications: dict[str, Any], full_url: str - ) -> dict[str, Any]: - headers = components["headers"].copy() - body = components["body"] - final_url = full_url - - if "params" in modifications: - parsed = urlparse(final_url) - params = {k: v[0] if v else "" for k, v in parse_qs(parsed.query).items()} - params.update(modifications["params"]) - final_url = urlunparse(parsed._replace(query=urlencode(params))) - - if "headers" in modifications: - headers.update(modifications["headers"]) - - if "body" in modifications: - body = modifications["body"] - - if "cookies" in modifications: - cookies = {} - 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, - } - - def _send_modified_request( - self, request_data: dict[str, Any], request_id: str, modifications: dict[str, Any] - ) -> dict[str, Any]: - try: - start_time = time.time() - response = requests.request( - method=request_data["method"], - url=request_data["url"], - headers=request_data["headers"], - data=request_data["body"] or None, - proxies=self.proxies, - timeout=30, - verify=False, - ) - response_time = int((time.time() - start_time) * 1000) - - response_body = response.text - truncated = len(response_body) > 10000 - if truncated: - response_body = response_body[:10000] + "\n... [truncated]" - - return { - "status_code": response.status_code, - "status_text": response.reason, - "headers": { - k: v - for k, v in response.headers.items() - if k.lower() - in ["content-type", "content-length", "server", "set-cookie", "location"] - }, - "body": response_body, - "body_truncated": truncated, - "body_size": len(response.content), - "response_time_ms": response_time, - "url": response.url, - "original_request_id": request_id, - "modifications_applied": modifications, - "request": { - "method": request_data["method"], - "url": request_data["url"], - "headers": request_data["headers"], - "has_body": bool(request_data["body"]), - }, - } - - except ProxyError as e: - return { - "error": "Proxy connection failed - is Caido running?", - "details": str(e), - "original_request_id": request_id, - } - except (RequestException, Timeout) as e: - return { - "error": f"Failed to repeat request: {type(e).__name__}", - "details": str(e), - "original_request_id": request_id, - } - - def _handle_scope_list(self) -> dict[str, Any]: - result = self._get_client().execute( - gql("query { scopes { id name allowlist denylist indexed } }") - ) - scopes = result.get("scopes", []) - return {"scopes": scopes, "count": len(scopes)} - - def _handle_scope_get(self, scope_id: str | None) -> dict[str, Any]: - if not scope_id: - return self._handle_scope_list() - - result = self._get_client().execute( - gql( - "query GetScope($id: ID!) { scope(id: $id) { id name allowlist denylist indexed } }" - ), - variable_values={"id": scope_id}, - ) - scope = result.get("scope") - if not scope: - return {"error": f"Scope {scope_id} not found"} - return {"scope": scope} - - def _handle_scope_create( - self, scope_name: str, allowlist: list[str] | None, denylist: list[str] | None - ) -> dict[str, Any]: - if not scope_name: - return {"error": "scope_name required for create"} - - mutation = gql(""" - mutation CreateScope($input: CreateScopeInput!) { - createScope(input: $input) { - scope { id name allowlist denylist indexed } - error { - ... on InvalidGlobTermsUserError { code terms } - ... on OtherUserError { code } - } - } - } - """) - - result = self._get_client().execute( - mutation, - variable_values={ - "input": { - "name": scope_name, - "allowlist": allowlist or [], - "denylist": denylist or [], - } - }, - ) - - payload = result.get("createScope", {}) - if payload.get("error"): - error = payload["error"] - return {"error": f"Invalid glob patterns: {error.get('terms', error.get('code'))}"} - - return {"scope": payload.get("scope"), "message": "Scope created successfully"} - - def _handle_scope_update( - self, - scope_id: str, - scope_name: str, - allowlist: list[str] | None, - denylist: list[str] | None, - ) -> dict[str, Any]: - if not scope_id or not scope_name: - return {"error": "scope_id and scope_name required"} - - mutation = gql(""" - mutation UpdateScope($id: ID!, $input: UpdateScopeInput!) { - updateScope(id: $id, input: $input) { - scope { id name allowlist denylist indexed } - error { - ... on InvalidGlobTermsUserError { code terms } - ... on OtherUserError { code } - } - } - } - """) - - result = self._get_client().execute( - mutation, - variable_values={ - "id": scope_id, - "input": { - "name": scope_name, - "allowlist": allowlist or [], - "denylist": denylist or [], - }, - }, - ) - - payload = result.get("updateScope", {}) - if payload.get("error"): - error = payload["error"] - return {"error": f"Invalid glob patterns: {error.get('terms', error.get('code'))}"} - - return {"scope": payload.get("scope"), "message": "Scope updated successfully"} - - def _handle_scope_delete(self, scope_id: str) -> dict[str, Any]: - if not scope_id: - return {"error": "scope_id required for delete"} - - result = self._get_client().execute( - gql("mutation DeleteScope($id: ID!) { deleteScope(id: $id) { deletedId } }"), - variable_values={"id": scope_id}, - ) - - payload = result.get("deleteScope", {}) - if not payload.get("deletedId"): - return {"error": f"Failed to delete scope {scope_id}"} - return {"message": f"Scope {scope_id} deleted", "deletedId": payload["deletedId"]} - - def scope_rules( - self, - action: str, - allowlist: list[str] | None = None, - denylist: list[str] | None = None, - scope_id: str | None = None, - scope_name: str | None = None, - ) -> dict[str, Any]: - handlers: dict[str, Callable[[], dict[str, Any]]] = { - "list": self._handle_scope_list, - "get": lambda: self._handle_scope_get(scope_id), - "create": lambda: ( - {"error": "scope_name required for create"} - if not scope_name - else self._handle_scope_create(scope_name, allowlist, denylist) - ), - "update": lambda: ( - {"error": "scope_id and scope_name required"} - if not scope_id or not scope_name - else self._handle_scope_update(scope_id, scope_name, allowlist, denylist) - ), - "delete": lambda: ( - {"error": "scope_id required for delete"} - if not scope_id - else self._handle_scope_delete(scope_id) - ), - } - - handler = handlers.get(action) - if not handler: - return { - "error": f"Unsupported action: {action}. Use 'get', 'list', 'create', " - f"'update', or 'delete'" - } - - try: - result = handler() - except (TransportQueryError, ValueError, KeyError) as e: - return {"error": f"Scope operation failed: {e}"} - else: - return result - - def list_sitemap( - self, - scope_id: str | None = None, - parent_id: str | None = None, - depth: str = "DIRECT", - page: int = 1, - page_size: int = 30, - ) -> dict[str, Any]: - try: - skip_count = (page - 1) * page_size - - if parent_id: - query = gql(""" - query GetSitemapDescendants($parentId: ID!, $depth: SitemapDescendantsDepth!) { - sitemapDescendantEntries(parentId: $parentId, depth: $depth) { - edges { - node { - id kind label hasDescendants - request { method path response { statusCode } } - } - } - count { value } - } - } - """) - result = self._get_client().execute( - query, variable_values={"parentId": parent_id, "depth": depth} - ) - data = result.get("sitemapDescendantEntries", {}) - else: - query = gql(""" - 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 } - } - } - """) - result = self._get_client().execute(query, variable_values={"scopeId": scope_id}) - data = result.get("sitemapRootEntries", {}) - - all_nodes = [edge["node"] for edge in data.get("edges", [])] - count_data = data.get("count") or {} - total_count = count_data.get("value", 0) - - paginated_nodes = all_nodes[skip_count : skip_count + page_size] - cleaned_nodes = [] - - for node in paginated_nodes: - cleaned = { - "id": node["id"], - "kind": node["kind"], - "label": node["label"], - "hasDescendants": node["hasDescendants"], - } - - if node.get("metadata") and ( - node["metadata"].get("isTls") is not None or node["metadata"].get("port") - ): - cleaned["metadata"] = node["metadata"] - - if node.get("request"): - req = node["request"] - cleaned_req = {} - if req.get("method"): - cleaned_req["method"] = req["method"] - if req.get("path"): - cleaned_req["path"] = req["path"] - response_data = req.get("response") or {} - if response_data.get("statusCode"): - cleaned_req["status"] = response_data["statusCode"] - if cleaned_req: - cleaned["request"] = cleaned_req - - cleaned_nodes.append(cleaned) - - total_pages = (total_count + page_size - 1) // page_size - - return { - "entries": cleaned_nodes, - "page": page, - "page_size": page_size, - "total_pages": total_pages, - "total_count": total_count, - "has_more": page < total_pages, - "showing": ( - f"{skip_count + 1}-{min(skip_count + page_size, total_count)} of {total_count}" - ), - } - - except (TransportQueryError, ValueError, KeyError) as e: - return {"error": f"Failed to fetch sitemap: {e}"} - - def _process_sitemap_metadata(self, node: dict[str, Any]) -> dict[str, Any]: - cleaned = { - "id": node["id"], - "kind": node["kind"], - "label": node["label"], - "hasDescendants": node["hasDescendants"], - } - - if node.get("metadata") and ( - node["metadata"].get("isTls") is not None or node["metadata"].get("port") - ): - cleaned["metadata"] = node["metadata"] - - return cleaned - - def _process_sitemap_request(self, req: dict[str, Any]) -> dict[str, Any] | None: - cleaned_req = {} - if req.get("method"): - cleaned_req["method"] = req["method"] - if req.get("path"): - cleaned_req["path"] = req["path"] - response_data = req.get("response") or {} - if response_data.get("statusCode"): - cleaned_req["status"] = response_data["statusCode"] - return cleaned_req if cleaned_req else None - - def _process_sitemap_response(self, resp: dict[str, Any]) -> dict[str, Any]: - cleaned_resp = {} - if resp.get("statusCode"): - cleaned_resp["status"] = resp["statusCode"] - if resp.get("length"): - cleaned_resp["size"] = resp["length"] - if resp.get("roundtripTime"): - cleaned_resp["time_ms"] = resp["roundtripTime"] - return cleaned_resp - - def view_sitemap_entry(self, entry_id: str) -> dict[str, Any]: - try: - query = gql(""" - 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 } - } - } - } - """) - - result = self._get_client().execute(query, variable_values={"id": entry_id}) - entry = result.get("sitemapEntry") - - if not entry: - return {"error": f"Sitemap entry {entry_id} not found"} - - cleaned = self._process_sitemap_metadata(entry) - - if entry.get("request"): - req = entry["request"] - cleaned_req = {} - if req.get("method"): - cleaned_req["method"] = req["method"] - if req.get("path"): - cleaned_req["path"] = req["path"] - if req.get("response"): - cleaned_req["response"] = self._process_sitemap_response(req["response"]) - if cleaned_req: - cleaned["request"] = cleaned_req - - requests_data = entry.get("requests", {}) - request_nodes = [edge["node"] for edge in requests_data.get("edges", [])] - - cleaned_requests = [ - req - for req in (self._process_sitemap_request(node) for node in request_nodes) - if req is not None - ] - - count_data = requests_data.get("count") or {} - cleaned["related_requests"] = { - "requests": cleaned_requests, - "total_count": count_data.get("value", 0), - "showing": f"Latest {len(cleaned_requests)} requests", - } - - return {"entry": cleaned} if cleaned else {"error": "Failed to process sitemap entry"} # noqa: TRY300 - - except (TransportQueryError, ValueError, KeyError) as e: - return {"error": f"Failed to fetch sitemap entry: {e}"} - - def close(self) -> None: - pass - - -_PROXY_MANAGER: ProxyManager | None = None - - -def get_proxy_manager() -> ProxyManager: - global _PROXY_MANAGER # noqa: PLW0603 - if _PROXY_MANAGER is None: - _PROXY_MANAGER = ProxyManager() - return _PROXY_MANAGER diff --git a/strix/tools/proxy/tools.py b/strix/tools/proxy/tools.py index 3e312bb..00bf29c 100644 --- a/strix/tools/proxy/tools.py +++ b/strix/tools/proxy/tools.py @@ -1,21 +1,40 @@ -"""SDK function-tool wrappers for the seven Caido proxy tools. +"""Caido proxy tools — host-side via ``caido-sdk-client``. -All seven dispatch to the in-container Caido manager via the sandbox -tool server. Same pattern as browser/terminal/python — host wrapper is -pure pass-through, no logic of its own. +The five tools delegate directly to ``caido_sdk_client.Client`` instances +held in the per-scan agent context. No sandbox round-trip; the SDK +talks GraphQL to the in-container Caido sidecar via the host-mapped +port resolved at session create time. -Tools: list_requests, view_request, send_request, repeat_request, -scope_rules, list_sitemap, view_sitemap_entry. +Tools: ``list_requests``, ``view_request``, ``send_request``, +``repeat_request``, ``scope_rules``. """ from __future__ import annotations -from typing import Any, Literal +import dataclasses +import re +import time +from dataclasses import is_dataclass +from datetime import datetime +from typing import TYPE_CHECKING, Any, Literal +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from agents import RunContextWrapper +from caido_sdk_client.types import ( + ConnectionInfoInput, + CreateReplaySessionFromRaw, + CreateReplaySessionOptions, + CreateScopeOptions, + ReplaySendOptions, + RequestGetOptions, + UpdateScopeOptions, +) from strix.tools._decorator import dump_tool_result, strix_tool -from strix.tools._sandbox_dispatch import post_to_sandbox + + +if TYPE_CHECKING: + from caido_sdk_client import Client RequestPart = Literal["request", "response"] @@ -30,17 +49,66 @@ SortBy = Literal[ "source", ] SortOrder = Literal["asc", "desc"] -SitemapDepth = Literal["DIRECT", "ALL"] ScopeAction = Literal["get", "list", "create", "update", "delete"] +_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 _ctx_client(ctx: RunContextWrapper) -> Client | None: + inner = ctx.context if isinstance(ctx.context, dict) else {} + return inner.get("caido_client") + + +def _serialize(value: Any) -> Any: + """Recursively convert SDK dataclasses/Pydantic objects to JSON-safe primitives.""" + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, bytes): + try: + return value.decode("utf-8", errors="replace") + except Exception: # noqa: BLE001 + return value.hex() + if isinstance(value, datetime): + return value.isoformat() + if is_dataclass(value) and not isinstance(value, type): + return {k: _serialize(v) for k, v in dataclasses.asdict(value).items()} + if hasattr(value, "model_dump"): + return _serialize(value.model_dump()) + if isinstance(value, dict): + return {str(k): _serialize(v) for k, v in value.items()} + if isinstance(value, list | tuple | set): + return [_serialize(v) for v in value] + return str(value) + + +def _no_client() -> str: + return dump_tool_result( + { + "success": False, + "error": "Caido client not initialized in context.", + }, + ) + + +# ---------------------------------------------------------------------- +# list_requests +# ---------------------------------------------------------------------- @strix_tool(timeout=120) async def list_requests( ctx: RunContextWrapper, httpql_filter: str | None = None, - start_page: int = 1, - end_page: int = 1, - page_size: int = 50, + first: int = 50, + after: str | None = None, sort_by: SortBy = "timestamp", sort_order: SortOrder = "desc", scope_id: str | None = None, @@ -64,34 +132,94 @@ async def list_requests( - **Special**: ``source:intercept`` (only intercepted requests), ``preset:"name"``. + For sitemap-style tree traversal use HTTPQL filters: drill into a + host with ``req.host.eq:"example.com"`` then narrow paths with + ``req.path.cont:"/api/"``. + + Pagination is cursor-based. Pass the ``end_cursor`` from the + ``page_info`` of one call as ``after`` to the next. + Args: - httpql_filter: Caido HTTPQL query. - start_page: Starting page, 1-indexed. - end_page: Ending page (inclusive). - page_size: Entries per page (default 50). - sort_by: ``timestamp`` / ``host`` / ``method`` / ``path`` / - ``status_code`` / ``response_time`` / ``response_size`` / - ``source``. + httpql_filter: Caido HTTPQL query (optional). + first: Number of entries to return (default 50). + after: Cursor from a previous response's ``page_info.end_cursor``. + sort_by: One of ``timestamp`` / ``host`` / ``method`` / ``path`` + / ``status_code`` / ``response_time`` / ``response_size`` + / ``source``. sort_order: ``asc`` or ``desc``. - scope_id: Restrict to a scope (managed via ``scope_rules``). + scope_id: Restrict to a Caido scope (managed via ``scope_rules``). """ - return dump_tool_result( - await post_to_sandbox( - ctx, - "list_requests", + client = _ctx_client(ctx) + if client is None: + return _no_client() + + try: + 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] + if sort_order == "asc": + builder = builder.ascending(target, field) + else: + builder = builder.descending(target, field) + + connection = await builder.execute() + + entries = [] + for edge in connection.edges: + req = edge.node.request + resp = edge.node.response + entries.append( + { + "cursor": edge.cursor, + "request": { + "id": req.id, + "host": req.host, + "port": req.port, + "method": req.method, + "path": req.path, + "query": req.query, + "is_tls": req.is_tls, + "created_at": req.created_at.isoformat(), + }, + "response": ( + { + "id": resp.id, + "status_code": resp.status_code, + "length": resp.length, + "roundtrip_ms": resp.roundtrip_time, + "created_at": resp.created_at.isoformat(), + } + if resp is not None + else None + ), + }, + ) + + return dump_tool_result( { - "httpql_filter": httpql_filter, - "start_page": start_page, - "end_page": end_page, - "page_size": page_size, - "sort_by": sort_by, - "sort_order": sort_order, - "scope_id": scope_id, + "success": True, + "entries": entries, + "page_info": { + "has_next_page": connection.page_info.has_next_page, + "has_previous_page": connection.page_info.has_previous_page, + "start_cursor": connection.page_info.start_cursor, + "end_cursor": connection.page_info.end_cursor, + }, }, - ), - ) + ) + except Exception as exc: # noqa: BLE001 + return dump_tool_result({"success": False, "error": f"list_requests failed: {exc}"}) +# ---------------------------------------------------------------------- +# view_request +# ---------------------------------------------------------------------- @strix_tool(timeout=60) async def view_request( ctx: RunContextWrapper, @@ -108,8 +236,8 @@ async def view_request( - **With** ``search_pattern`` (compact regex hits) — returns up to 20 matches with ``before`` / ``after`` context and position. Useful for hunting reflected input, leaked URLs, hidden parameters. - - **Without** ``search_pattern`` (full content with pagination) — - returns the page of raw content plus ``has_more`` flag. + - **Without** ``search_pattern`` (full content with line pagination) + — returns the page of raw content plus ``has_more`` flag. Common search patterns: @@ -126,24 +254,85 @@ async def view_request( page: 1-indexed page number (only when no ``search_pattern``). page_size: Lines per page. """ - return dump_tool_result( - await post_to_sandbox( - ctx, - "view_request", + client = _ctx_client(ctx) + if client is None: + return _no_client() + + try: + opts = RequestGetOptions( + request_raw=(part == "request"), + response_raw=(part == "response"), + ) + result = await client.request.get(request_id, opts) + if result is None: + return dump_tool_result( + {"success": False, "error": f"Request {request_id} not found"}, + ) + + raw_bytes = ( + result.request.raw + if part == "request" + else (result.response.raw if result.response is not None else None) + ) + if raw_bytes is None: + return dump_tool_result( + { + "success": False, + "error": f"No raw {part} for {request_id}", + }, + ) + content = raw_bytes.decode("utf-8", errors="replace") + + if search_pattern: + return dump_tool_result(_regex_hits(content, search_pattern)) + + return dump_tool_result(_paginate_lines(content, page=page, page_size=page_size)) + except Exception as exc: # noqa: BLE001 + return dump_tool_result({"success": False, "error": f"view_request failed: {exc}"}) + + +def _regex_hits(content: str, pattern: str) -> dict[str, Any]: + try: + regex = re.compile(pattern) + except re.error as exc: + return {"success": False, "error": f"Invalid regex: {exc}"} + + hits = [] + for match in regex.finditer(content): + start, end = match.span() + before = content[max(0, start - 40) : start] + after = content[end : end + 40] + hits.append( { - "request_id": request_id, - "part": part, - "search_pattern": search_pattern, - "page": page, - "page_size": page_size, + "match": match.group(0), + "position": start, + "before": before, + "after": after, }, - ), - ) + ) + if len(hits) >= 20: + break + + return {"success": True, "hits": hits, "total_hits": len(hits)} -# strict_mode=False because ``headers`` is a free-form dict — the model -# can't enumerate all possible HTTP headers, and the SDK's strict JSON -# schema rejects ``additionalProperties: true``. +def _paginate_lines(content: str, *, page: int, page_size: int) -> dict[str, Any]: + lines = content.splitlines() + start = max(0, (page - 1) * page_size) + end = start + page_size + return { + "success": True, + "content": "\n".join(lines[start:end]), + "page": page, + "page_size": page_size, + "total_lines": len(lines), + "has_more": end < len(lines), + } + + +# ---------------------------------------------------------------------- +# send_request +# ---------------------------------------------------------------------- @strix_tool(timeout=120, strict_mode=False) async def send_request( ctx: RunContextWrapper, @@ -167,24 +356,23 @@ async def send_request( body: Optional request body string. timeout: Per-request timeout in seconds (default 30). """ - return dump_tool_result( - await post_to_sandbox( - ctx, - "send_request", - { - "method": method, - "url": url, - "headers": headers or {}, - "body": body, - "timeout": timeout, - }, - ), - ) + del timeout # The SDK applies its own timeout via the GraphQL settings. + client = _ctx_client(ctx) + if client is None: + return _no_client() + + try: + connection, raw = _build_raw_request( + method=method, url=url, headers=headers or {}, body=body + ) + return await _replay_send(client, raw=raw, connection=connection) + except Exception as exc: # noqa: BLE001 + return dump_tool_result({"success": False, "error": f"send_request failed: {exc}"}) -# strict_mode=False because ``modifications`` is a free-form patch dict -# (header overrides, body replacements, query-string tweaks) the model -# composes per-call. +# ---------------------------------------------------------------------- +# repeat_request +# ---------------------------------------------------------------------- @strix_tool(timeout=120, strict_mode=False) async def repeat_request( ctx: RunContextWrapper, @@ -216,18 +404,37 @@ async def repeat_request( - ``body`` — replace the body string entirely. - ``cookies`` — dict of cookies to add/update. """ - return dump_tool_result( - await post_to_sandbox( - ctx, - "repeat_request", - { - "request_id": request_id, - "modifications": modifications or {}, - }, - ), - ) + client = _ctx_client(ctx) + if client is None: + return _no_client() + mods = modifications or {} + + try: + result = await client.request.get(request_id, RequestGetOptions(request_raw=True)) + if result is None or result.request.raw is None: + return dump_tool_result( + {"success": False, "error": 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(client, raw=raw, connection=connection) + except Exception as exc: # noqa: BLE001 + return dump_tool_result({"success": False, "error": f"repeat_request failed: {exc}"}) +# ---------------------------------------------------------------------- +# scope_rules +# ---------------------------------------------------------------------- @strix_tool(timeout=60) async def scope_rules( ctx: RunContextWrapper, @@ -255,14 +462,13 @@ async def scope_rules( "*woff*", "*.ttf"]``. Each scope has a unique id usable as ``scope_id`` in - ``list_requests`` / ``list_sitemap`` / ``view_request``. + ``list_requests``. Args: action: - ``list`` — return all scopes. - - ``get`` — single scope by ``scope_id`` (or all when - omitted). + - ``get`` — single scope by ``scope_id``. - ``create`` — needs ``scope_name``, optionally ``allowlist`` / ``denylist``. - ``update`` — needs ``scope_id`` + ``scope_name``; @@ -275,79 +481,202 @@ async def scope_rules( scope_id: Required for ``get`` / ``update`` / ``delete``. scope_name: Required for ``create`` / ``update``. """ - return dump_tool_result( - await post_to_sandbox( - ctx, - "scope_rules", - { - "action": action, - "allowlist": allowlist, - "denylist": denylist, - "scope_id": scope_id, - "scope_name": scope_name, - }, - ), - ) + client = _ctx_client(ctx) + if client is None: + return _no_client() + + try: + if action == "list": + scopes = await client.scope.list() + return dump_tool_result( + {"success": True, "scopes": [_serialize(s) for s in scopes]}, + ) + if action == "get": + if not scope_id: + return dump_tool_result( + {"success": False, "error": "scope_id required for get"}, + ) + scope = await client.scope.get(scope_id) + return dump_tool_result({"success": True, "scope": _serialize(scope)}) + if action == "create": + if not scope_name: + return dump_tool_result( + {"success": False, "error": "scope_name required for create"}, + ) + scope = await client.scope.create( + CreateScopeOptions( + name=scope_name, + allowlist=list(allowlist or []), + denylist=list(denylist or []), + ), + ) + return dump_tool_result({"success": True, "scope": _serialize(scope)}) + if action == "update": + if not scope_id or not scope_name: + return dump_tool_result( + { + "success": False, + "error": "scope_id and scope_name required for update", + }, + ) + scope = await client.scope.update( + scope_id, + UpdateScopeOptions( + name=scope_name, + allowlist=list(allowlist or []), + denylist=list(denylist or []), + ), + ) + return dump_tool_result({"success": True, "scope": _serialize(scope)}) + # action == "delete" — exhaustive Literal + if not scope_id: + return dump_tool_result( + {"success": False, "error": "scope_id required for delete"}, + ) + await client.scope.delete(scope_id) + return dump_tool_result({"success": True, "deleted": scope_id}) + except Exception as exc: # noqa: BLE001 + return dump_tool_result({"success": False, "error": f"scope_rules failed: {exc}"}) -@strix_tool(timeout=60) -async def list_sitemap( - ctx: RunContextWrapper, - scope_id: str | None = None, - parent_id: str | None = None, - depth: SitemapDepth = "DIRECT", - page: int = 1, +# ---------------------------------------------------------------------- +# Helpers — request build / parse / modify +# ---------------------------------------------------------------------- +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 + + +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: - """View the hierarchical sitemap of discovered attack surface. + 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']}" - The sitemap is built from proxied traffic — every URL the target - served gets indexed into a tree of domains → directories → request - leaves. Use it to understand application structure and find - interesting endpoints, hidden directories, parameter variations. - Entry kinds you'll encounter: +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 - - ``DOMAIN`` — root host (``example.com``). - - ``DIRECTORY`` — path segment (``/api/``, ``/admin/``). - - ``REQUEST`` — a specific endpoint. - - ``REQUEST_BODY`` — POST/PUT body variations (different payloads - seen at the same URL). - - ``REQUEST_QUERY`` — query-string variations. + 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))) - Each entry has ``hasDescendants`` — set ``parent_id`` to that - entry's id to drill in. Pages return 30 entries each. + if "headers" in modifications: + headers.update(modifications["headers"]) - Args: - scope_id: Filter to a specific scope. - parent_id: Drill into a subtree. ``None`` returns root domains. - depth: ``"DIRECT"`` (immediate children) or ``"ALL"`` (recursive). - page: 1-indexed page (30 entries/page). - """ - return dump_tool_result( - await post_to_sandbox( - ctx, - "list_sitemap", - { - "scope_id": scope_id, - "parent_id": parent_id, - "depth": depth, - "page": page, - }, + 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, + } + + +async def _replay_send( + client: Client, + *, + raw: bytes, + connection: ConnectionInfoInput, +) -> str: + started = time.time() + session = await client.replay.sessions.create( + CreateReplaySessionOptions( + request_source=CreateReplaySessionFromRaw(raw=raw, connection=connection), ), ) - - -@strix_tool(timeout=60) -async def view_sitemap_entry(ctx: RunContextWrapper, entry_id: str) -> str: - """Examine one sitemap entry — full metadata + every related request. - - Use this after ``list_sitemap`` identifies an interesting directory - or endpoint to see all the requests captured under it (methods, - paths, response codes, timing). - - Args: - entry_id: Sitemap entry id from ``list_sitemap``. - """ - return dump_tool_result( - await post_to_sandbox(ctx, "view_sitemap_entry", {"entry_id": entry_id}), + result = await client.replay.send( + session.id, + ReplaySendOptions(raw=raw, connection=connection), + ) + elapsed_ms = int((time.time() - started) * 1000) + + response: dict[str, Any] | None = None + response_raw = result.entry.response_raw if hasattr(result.entry, "response_raw") else None + if response_raw is not None: + response = { + "raw": response_raw.decode("utf-8", errors="replace"), + } + + return dump_tool_result( + { + "success": result.status == "DONE", + "status": result.status, + "error": result.error, + "session_id": str(session.id), + "elapsed_ms": elapsed_ms, + "response": response, + }, ) diff --git a/strix/tools/python/python_instance.py b/strix/tools/python/python_instance.py index 16ff1f7..02165da 100644 --- a/strix/tools/python/python_instance.py +++ b/strix/tools/python/python_instance.py @@ -25,27 +25,6 @@ class PythonInstance: self.shell.init_history() self.shell.init_logger() - self._setup_proxy_functions() - - def _setup_proxy_functions(self) -> None: - try: - from strix.tools.proxy import proxy_actions - - proxy_functions = [ - "list_requests", - "list_sitemap", - "repeat_request", - "scope_rules", - "send_request", - "view_request", - "view_sitemap_entry", - ] - - proxy_dict = {name: getattr(proxy_actions, name) for name in proxy_functions} - self.shell.user_ns.update(proxy_dict) - except ImportError: - pass - def _validate_session(self) -> dict[str, Any] | None: if not self.is_running: return { diff --git a/uv.lock b/uv.lock index 3687e68..96b880c 100644 --- a/uv.lock +++ b/uv.lock @@ -292,6 +292,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, ] +[[package]] +name = "caido-sdk-client" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caido-server-auth" }, + { name = "gql", extra = ["aiohttp", "websockets"] }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/d7/5381d8d94fec799bec7004decbf33a4c5581a8374941fe784e730e01cf80/caido_sdk_client-0.2.0.tar.gz", hash = "sha256:39988fe07b3fa9c69adbd49662db660d7707d60d9245109b1623def97b39bac8", size = 57436, upload-time = "2026-04-12T22:25:12.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/ae/3530caa6a79bafb8049374ca09515686d98532aca73c4fdbd0f6e06de5c9/caido_sdk_client-0.2.0-py3-none-any.whl", hash = "sha256:bc573651681c093ee9663c7924d38d522a89cea60e2ce00d34ba9b02942b1da1", size = 96207, upload-time = "2026-04-12T22:25:11.168Z" }, +] + +[[package]] +name = "caido-server-auth" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gql", extra = ["aiohttp", "websockets"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/be/58cc2eaf97f729124b8939a9ea1c1a664b2d96dce0448788df073fca3ac9/caido_server_auth-0.1.2.tar.gz", hash = "sha256:eb2c25e9de15062760b68112f5d8e9ad63eeb1322518b90c1a0119a69a7524a4", size = 6559, upload-time = "2026-03-14T20:41:55.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/7b/14d192151bcc3c1624cfb488c59ec03e96c1009d015089d729c1aecd26e9/caido_server_auth-0.1.2-py3-none-any.whl", hash = "sha256:40c6cd3728e24cdff402c4efa5d8f55bf6e6cc73ac0169bdea1ad1e34faff8ff", size = 10197, upload-time = "2026-03-14T20:41:54.091Z" }, +] + [[package]] name = "catalogue" version = "2.0.10" @@ -969,9 +995,11 @@ wheels = [ ] [package.optional-dependencies] -requests = [ - { name = "requests" }, - { name = "requests-toolbelt" }, +aiohttp = [ + { name = "aiohttp" }, +] +websockets = [ + { name = "websockets" }, ] [[package]] @@ -3128,18 +3156,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, -] - [[package]] name = "rich" version = "13.9.4" @@ -3527,6 +3543,8 @@ name = "strix-agent" version = "0.8.3" source = { editable = "." } dependencies = [ + { name = "aiohttp" }, + { name = "caido-sdk-client" }, { name = "cvss" }, { name = "docker" }, { name = "openai-agents", extra = ["litellm"] }, @@ -3540,7 +3558,6 @@ dependencies = [ [package.optional-dependencies] sandbox = [ { name = "fastapi" }, - { name = "gql", extra = ["requests"] }, { name = "ipython" }, { name = "libtmux" }, { name = "openhands-aci" }, @@ -3560,10 +3577,11 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiohttp", specifier = ">=3.10.0" }, + { name = "caido-sdk-client", specifier = ">=0.2.0" }, { name = "cvss", specifier = ">=3.2" }, { name = "docker", specifier = ">=7.1.0" }, { name = "fastapi", marker = "extra == 'sandbox'" }, - { name = "gql", extras = ["requests"], marker = "extra == 'sandbox'", specifier = ">=3.5.3" }, { name = "ipython", marker = "extra == 'sandbox'", specifier = ">=9.3.0" }, { name = "libtmux", marker = "extra == 'sandbox'", specifier = ">=0.46.2" }, { name = "openai-agents", extras = ["litellm"], specifier = "==0.14.6" },