first commit
This commit is contained in:
@@ -0,0 +1,682 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,596 @@
|
||||
"""Caido proxy host-side @function_tool wrappers around caido_api.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import is_dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
from strix.tools.proxy import caido_api
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from caido_sdk_client import Client
|
||||
|
||||
from strix.tools.proxy.caido_api import (
|
||||
RequestPart,
|
||||
SitemapDepth,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
)
|
||||
else:
|
||||
from strix.tools.proxy.caido_api import ( # noqa: TC001
|
||||
RequestPart,
|
||||
SitemapDepth,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
)
|
||||
|
||||
|
||||
ScopeAction = Literal["get", "list", "create", "update", "delete"]
|
||||
|
||||
|
||||
def _ctx_client(ctx: RunContextWrapper) -> Client | None:
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
return inner.get("caido_client")
|
||||
|
||||
|
||||
def _to_tool_json(value: Any) -> Any:
|
||||
"""Recursively convert SDK dataclasses/Pydantic objects to tool JSON values."""
|
||||
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: _to_tool_json(v) for k, v in dataclasses.asdict(value).items()}
|
||||
if hasattr(value, "model_dump"):
|
||||
return _to_tool_json(value.model_dump())
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _to_tool_json(v) for k, v in value.items()}
|
||||
if isinstance(value, list | tuple | set):
|
||||
return [_to_tool_json(v) for v in value]
|
||||
return str(value)
|
||||
|
||||
|
||||
def _no_client() -> str:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Caido client not available in run context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
def _err(name: str, exc: Exception) -> str:
|
||||
logger.exception("%s failed", name)
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"{name} failed: {exc}"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=120)
|
||||
async def list_requests(
|
||||
ctx: RunContextWrapper,
|
||||
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,
|
||||
) -> str:
|
||||
"""List captured HTTP requests from the Caido proxy with HTTPQL filtering.
|
||||
|
||||
Caido HTTPQL syntax (operators differ by field type):
|
||||
|
||||
- **Integer fields** (``resp.code``, ``req.port``, ``id``,
|
||||
``roundtrip``) — ``eq``, ``gt``, ``gte``, ``lt``, ``lte``, ``ne``.
|
||||
Examples: ``resp.code.eq:200``, ``resp.code.gte:400``,
|
||||
``req.port.eq:443``.
|
||||
- **Text/byte fields** (``req.method``, ``req.host``, ``req.path``,
|
||||
``req.query``, ``req.ext``, ``req.raw``) — ``regex``, ``cont``
|
||||
(substring), ``eq``. Examples: ``req.method.eq:"POST"``,
|
||||
``req.path.cont:"/api/"``, ``req.host.regex:".*\\.example\\.com"``.
|
||||
- **Date fields** (``req.created_at``) — ``gt``, ``lt`` with ISO
|
||||
timestamps: ``req.created_at.gt:"2024-01-01T00:00:00Z"``.
|
||||
- **Combine** with ``AND`` / ``OR``: ``req.method.eq:"POST" AND
|
||||
resp.code.gte:400``.
|
||||
- **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.
|
||||
|
||||
Notes:
|
||||
|
||||
- HTTPQL has **no ``NOT`` operator**. Use the negated form of the
|
||||
operator instead: ``ne``, ``ncont``, ``nlike``, ``nregex``
|
||||
(e.g. ``req.path.ncont:"/static"`` to exclude static paths).
|
||||
- String values **must be quoted**; integer values **must not**.
|
||||
``resp.code.eq:200`` is right; ``resp.code.eq:"200"`` is a parse
|
||||
error. Same rule for ``cont`` / ``regex`` strings.
|
||||
- A bare quoted string searches both ``req.raw`` and ``resp.raw``,
|
||||
handy for sensitive-data sweeps:
|
||||
``"password" OR "secret" OR "api_key"``.
|
||||
|
||||
Args:
|
||||
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 Caido scope (managed via ``scope_rules``).
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
|
||||
try:
|
||||
connection = await caido_api.list_requests_with_client(
|
||||
client,
|
||||
httpql_filter=httpql_filter,
|
||||
first=first,
|
||||
after=after,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
|
||||
entries = []
|
||||
for edge in connection.edges:
|
||||
req = edge.node.request
|
||||
resp = edge.node.response
|
||||
response_payload: dict[str, Any] | None = None
|
||||
if resp is not None:
|
||||
response_payload = {
|
||||
"id": resp.id,
|
||||
"status_code": resp.status_code,
|
||||
"length": resp.length,
|
||||
"created_at": resp.created_at.isoformat(),
|
||||
}
|
||||
# Caido populates ``roundtripTime`` for some traffic sources
|
||||
# and leaves it as ``0`` for others (notably proxy captures
|
||||
# of upstream env-routed traffic). Surface the value only
|
||||
# when it's actually measured so the model doesn't waste
|
||||
# tokens reading a zero field on every entry.
|
||||
if resp.roundtrip_time:
|
||||
response_payload["roundtrip_ms"] = resp.roundtrip_time
|
||||
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": response_payload,
|
||||
},
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"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,
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("list_requests", exc)
|
||||
|
||||
|
||||
@function_tool(timeout=60)
|
||||
async def view_request(
|
||||
ctx: RunContextWrapper,
|
||||
request_id: str,
|
||||
part: RequestPart = "request",
|
||||
search_pattern: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> str:
|
||||
"""View a captured request or its response, optionally regex-searched.
|
||||
|
||||
Two modes:
|
||||
|
||||
- **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 line pagination)
|
||||
— returns the page of raw content plus ``has_more`` flag.
|
||||
|
||||
Common search patterns:
|
||||
|
||||
- API endpoints: ``/api/[a-zA-Z0-9._/-]+``
|
||||
- URLs: ``https?://[^\\s<>"']+``
|
||||
- Query parameters: ``[?&][a-zA-Z0-9_]+=([^&\\s<>"']+)``
|
||||
- Specific input reflection: search for the value you submitted.
|
||||
|
||||
Args:
|
||||
request_id: Request ID from ``list_requests``.
|
||||
part: ``"request"`` or ``"response"``.
|
||||
search_pattern: Optional regex; switches the response shape to
|
||||
compact hits.
|
||||
page: 1-indexed page number (only when no ``search_pattern``).
|
||||
page_size: Lines per page.
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
|
||||
try:
|
||||
result = await caido_api.get_request_with_client(client, request_id, part=part)
|
||||
if result is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"Request {request_id} not found"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
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 json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"No raw {part} for {request_id}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
content = raw_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
if search_pattern:
|
||||
return json.dumps(
|
||||
_format_search_hits(content, search_pattern),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
_format_text_page(content, page=page, page_size=page_size),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("view_request", exc)
|
||||
|
||||
|
||||
def _format_search_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(
|
||||
{
|
||||
"match": match.group(0),
|
||||
"position": start,
|
||||
"before": before,
|
||||
"after": after,
|
||||
},
|
||||
)
|
||||
if len(hits) >= 20:
|
||||
break
|
||||
|
||||
return {"success": True, "hits": hits, "total_hits": len(hits)}
|
||||
|
||||
|
||||
def _format_text_page(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),
|
||||
}
|
||||
|
||||
|
||||
@function_tool(timeout=120, strict_mode=False)
|
||||
async def repeat_request(
|
||||
ctx: RunContextWrapper,
|
||||
request_id: str,
|
||||
modifications: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Repeat a captured request, optionally patching individual fields.
|
||||
|
||||
The standard pentesting workflow with this tool:
|
||||
|
||||
1. ``agent-browser`` (via ``exec_command``) or live target traffic
|
||||
→ request gets captured by Caido.
|
||||
2. ``list_requests`` → find the request ID you want to manipulate.
|
||||
3. ``repeat_request`` → send a modified version (auth-bypass test,
|
||||
payload injection, parameter tampering).
|
||||
|
||||
Mirrors the manual "browse → capture → modify → test" flow used in
|
||||
real pentesting. Inherits everything from the original request
|
||||
(headers, cookies, auth, method, URL) and overlays only the fields
|
||||
you specify in ``modifications``.
|
||||
|
||||
Args:
|
||||
request_id: ID of the original request (from ``list_requests``).
|
||||
modifications: Patch dict. Recognized keys:
|
||||
|
||||
- ``url`` — replace the URL.
|
||||
- ``params`` — dict of query-string keys to add/update.
|
||||
- ``headers`` — dict of headers to add/update.
|
||||
- ``body`` — replace the body string entirely.
|
||||
- ``cookies`` — dict of cookies to add/update.
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
mods = modifications or {}
|
||||
|
||||
try:
|
||||
result = await caido_api.get_request_with_client(client, request_id, part="request")
|
||||
if result is None or result.request.raw is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"Request {request_id} not found"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
original = result.request
|
||||
raw_str = result.request.raw.decode("utf-8", errors="replace")
|
||||
components = caido_api.parse_raw_request(raw_str)
|
||||
full_url = caido_api.full_url_from_components(original, components, mods)
|
||||
modified = caido_api.apply_modifications(components, mods, full_url)
|
||||
connection, raw = caido_api.build_raw_request(
|
||||
method=modified["method"],
|
||||
url=modified["url"],
|
||||
headers=modified["headers"],
|
||||
body=modified["body"],
|
||||
)
|
||||
replay = await caido_api.replay_send_raw(client, raw=raw, connection=connection)
|
||||
return _format_replay_tool_result(replay)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("repeat_request", exc)
|
||||
|
||||
|
||||
def _format_replay_tool_result(replay: dict[str, Any]) -> str:
|
||||
response = caido_api.parse_raw_response(replay.get("response_raw"))
|
||||
payload: dict[str, Any] = {
|
||||
"success": replay["status"] == "DONE",
|
||||
"status": replay["status"],
|
||||
"session_id": replay["session_id"],
|
||||
"elapsed_ms": replay["elapsed_ms"],
|
||||
"response": response,
|
||||
}
|
||||
if replay.get("error"):
|
||||
payload["error"] = replay["error"]
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
@function_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,
|
||||
) -> str:
|
||||
"""Browse Caido's hierarchical sitemap of proxied traffic.
|
||||
|
||||
Caido aggregates every captured request into a tree:
|
||||
``DOMAIN`` → ``DIRECTORY`` (path segments) → ``REQUEST`` →
|
||||
``REQUEST_BODY`` / ``REQUEST_QUERY`` (variant per body/query shape).
|
||||
Use this to understand the discovered attack surface, locate
|
||||
promising directories, and pick endpoints worth deeper testing.
|
||||
|
||||
Workflow:
|
||||
- Start with no ``parent_id`` to list root domains (scoped by
|
||||
``scope_id`` if you only care about in-scope hosts).
|
||||
- Pick an entry where ``has_descendants=true`` and pass its ``id``
|
||||
as ``parent_id`` to drill in. ``depth="DIRECT"`` returns only
|
||||
immediate children; ``"ALL"`` flattens the full subtree.
|
||||
- Hand any ``id`` to ``view_sitemap_entry`` for the full record
|
||||
and recent matching requests.
|
||||
|
||||
Args:
|
||||
scope_id: Limit roots to a Caido scope (only used when
|
||||
``parent_id`` is omitted). Manage scopes via ``scope_rules``.
|
||||
parent_id: Entry ID to expand; omit for root domains.
|
||||
depth: ``"DIRECT"`` (immediate children) or ``"ALL"``
|
||||
(recursive subtree). Only meaningful with ``parent_id``.
|
||||
page: 1-indexed page (30 entries per page).
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
try:
|
||||
payload = await caido_api.list_sitemap_with_client(
|
||||
client,
|
||||
scope_id=scope_id,
|
||||
parent_id=parent_id,
|
||||
depth=depth,
|
||||
page=page,
|
||||
)
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("list_sitemap", exc)
|
||||
|
||||
|
||||
@function_tool(timeout=60)
|
||||
async def view_sitemap_entry(
|
||||
ctx: RunContextWrapper,
|
||||
entry_id: str,
|
||||
) -> str:
|
||||
"""Get full detail for a sitemap entry plus its recent requests.
|
||||
|
||||
Returns the entry's metadata, the primary request shape
|
||||
(method/path/response if any), and the most recent 30 related
|
||||
requests that fall under this entry. Pair with ``list_sitemap`` to
|
||||
pick the ``entry_id``.
|
||||
|
||||
Args:
|
||||
entry_id: ID from ``list_sitemap`` (or any nested entry).
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
try:
|
||||
payload = await caido_api.view_sitemap_entry_with_client(client, entry_id)
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("view_sitemap_entry", exc)
|
||||
|
||||
|
||||
@function_tool(timeout=60)
|
||||
async def scope_rules(
|
||||
ctx: RunContextWrapper,
|
||||
action: ScopeAction,
|
||||
allowlist: list[str] | None = None,
|
||||
denylist: list[str] | None = None,
|
||||
scope_id: str | None = None,
|
||||
scope_name: str | None = None,
|
||||
) -> str:
|
||||
"""CRUD on Caido scope rules (allow/deny patterns).
|
||||
|
||||
Scopes filter which traffic Caido tools see. Use them to focus on a
|
||||
target, exclude noisy assets (CDNs, static files), or define a
|
||||
bug-bounty allowlist.
|
||||
|
||||
Pattern semantics:
|
||||
|
||||
- Glob wildcards: ``*`` (any), ``?`` (single), ``[abc]`` (one of),
|
||||
``[a-z]`` (range), ``[^abc]`` (none of).
|
||||
- **Empty allowlist = allow all domains.**
|
||||
- **Denylist always overrides allowlist.**
|
||||
|
||||
Common denylist for noisy static assets:
|
||||
``["*.gif", "*.jpg", "*.png", "*.css", "*.js", "*.ico", "*.svg",
|
||||
"*woff*", "*.ttf"]``.
|
||||
|
||||
Each scope has a unique id usable as ``scope_id`` in
|
||||
``list_requests``.
|
||||
|
||||
Args:
|
||||
action:
|
||||
|
||||
- ``list`` — return all scopes.
|
||||
- ``get`` — single scope by ``scope_id``.
|
||||
- ``create`` — needs ``scope_name``, optionally
|
||||
``allowlist`` / ``denylist``.
|
||||
- ``update`` — needs ``scope_id`` + ``scope_name``;
|
||||
allowlist / denylist replace the previous values.
|
||||
- ``delete`` — needs ``scope_id``.
|
||||
|
||||
allowlist: Domain patterns to include (e.g.
|
||||
``["*.example.com", "api.test.com"]``).
|
||||
denylist: Patterns to exclude.
|
||||
scope_id: Required for ``get`` / ``update`` / ``delete``.
|
||||
scope_name: Required for ``create`` / ``update``.
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
|
||||
try:
|
||||
if action == "list":
|
||||
scopes = await caido_api.scope_list(client)
|
||||
return json.dumps(
|
||||
{"success": True, "scopes": [_to_tool_json(s) for s in scopes]},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
if action == "get":
|
||||
if not scope_id:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Scope_id is required for action='get'"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
scope = await caido_api.scope_get(client, scope_id)
|
||||
return json.dumps(
|
||||
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
|
||||
)
|
||||
if action == "create":
|
||||
if not scope_name:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Scope_name is required for action='create'"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
scope = await caido_api.scope_create(
|
||||
client, name=scope_name, allowlist=allowlist, denylist=denylist
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
|
||||
)
|
||||
if action == "update":
|
||||
if not scope_id or not scope_name:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Scope_id and scope_name are required for action='update'",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
scope = await caido_api.scope_update(
|
||||
client, scope_id, name=scope_name, allowlist=allowlist, denylist=denylist
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
|
||||
)
|
||||
if not scope_id:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Scope_id is required for action='delete'"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
await caido_api.scope_delete(client, scope_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"deleted": scope_id,
|
||||
"message": f"Scope {scope_id} deleted",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("scope_rules", exc)
|
||||
Reference in New Issue
Block a user