mirror of
https://github.com/usestrix/strix.git
synced 2026-08-22 11:02:08 +02:00
Open-source release for Alpha version
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
from .proxy_actions import (
|
||||
list_requests,
|
||||
list_sitemap,
|
||||
repeat_request,
|
||||
scope_rules,
|
||||
send_request,
|
||||
view_request,
|
||||
view_sitemap_entry,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"list_requests",
|
||||
"list_sitemap",
|
||||
"repeat_request",
|
||||
"scope_rules",
|
||||
"send_request",
|
||||
"view_request",
|
||||
"view_sitemap_entry",
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from strix.tools.registry import register_tool
|
||||
|
||||
from .proxy_manager import get_proxy_manager
|
||||
|
||||
|
||||
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]:
|
||||
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]:
|
||||
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]:
|
||||
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]:
|
||||
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]:
|
||||
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]:
|
||||
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]:
|
||||
manager = get_proxy_manager()
|
||||
return manager.view_sitemap_entry(entry_id)
|
||||
@@ -0,0 +1,267 @@
|
||||
<?xml version="1.0" ?>
|
||||
<tools>
|
||||
<tool name="list_requests">
|
||||
<description>List and filter proxy requests using HTTPQL with pagination.</description>
|
||||
<parameters>
|
||||
<parameter name="httpql_filter" type="string" required="false">
|
||||
<description>HTTPQL filter using Caido's syntax:
|
||||
|
||||
Integer fields (port, code, roundtrip, id) - eq, gt, gte, lt, lte, ne:
|
||||
- resp.code.eq:200, resp.code.gte:400, req.port.eq:443
|
||||
|
||||
Text/byte fields (ext, host, method, path, query, raw) - regex:
|
||||
- req.method.regex:"POST", req.path.regex:"/api/.*", req.host.regex:".*.com"
|
||||
|
||||
Date fields (created_at) - gt, lt with ISO formats:
|
||||
- req.created_at.gt:"2024-01-01T00:00:00Z"
|
||||
|
||||
Special: source:intercept, preset:"name"</description>
|
||||
</parameter>
|
||||
<parameter name="start_page" type="integer" required="false">
|
||||
<description>Starting page (1-based)</description>
|
||||
</parameter>
|
||||
<parameter name="end_page" type="integer" required="false">
|
||||
<description>Ending page (1-based, inclusive)</description>
|
||||
</parameter>
|
||||
<parameter name="page_size" type="integer" required="false">
|
||||
<description>Requests per page</description>
|
||||
</parameter>
|
||||
<parameter name="sort_by" type="string" required="false">
|
||||
<description>Sort field from: "timestamp", "host", "status_code", "response_time", "response_size"</description>
|
||||
</parameter>
|
||||
<parameter name="sort_order" type="string" required="false">
|
||||
<description>Sort direction ("asc" or "desc")</description>
|
||||
</parameter>
|
||||
<parameter name="scope_id" type="string" required="false">
|
||||
<description>Scope ID to filter requests (use scope_rules to manage scopes)</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing:
|
||||
- 'requests': Request objects for page range
|
||||
- 'total_count': Total matching requests
|
||||
- 'start_page', 'end_page', 'page_size': Query parameters
|
||||
- 'returned_count': Requests in response</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# POST requests to API with 200 responses
|
||||
<function=list_requests>
|
||||
<parameter=httpql_filter>req.method.eq:"POST" AND req.path.cont:"/api/"</parameter>
|
||||
<parameter=sort_by>response_time</parameter>
|
||||
<parameter=scope_id>scope123</parameter>
|
||||
</function>
|
||||
|
||||
# Requests within specific scope
|
||||
<function=list_requests>
|
||||
<parameter=scope_id>scope123</parameter>
|
||||
<parameter=sort_by>timestamp</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="view_request">
|
||||
<description>View request/response data with search and pagination.</description>
|
||||
<parameters>
|
||||
<parameter name="request_id" type="string" required="true">
|
||||
<description>Request ID</description>
|
||||
</parameter>
|
||||
<parameter name="part" type="string" required="false">
|
||||
<description>Which part to return ("request" or "response")</description>
|
||||
</parameter>
|
||||
<parameter name="search_pattern" type="string" required="false">
|
||||
<description>Regex pattern to search content. Common patterns:
|
||||
- API endpoints: r"/api/[a-zA-Z0-9._/-]+"
|
||||
- URLs: r"https?://[^\\s<>"\']+"
|
||||
- Parameters: r'[?&][a-zA-Z0-9_]+=([^&\\s<>"\']+)'
|
||||
- Reflections: input_value in content</description>
|
||||
</parameter>
|
||||
<parameter name="page" type="integer" required="false">
|
||||
<description>Page number for pagination</description>
|
||||
</parameter>
|
||||
<parameter name="page_size" type="integer" required="false">
|
||||
<description>Lines per page</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>With search_pattern (COMPACT):
|
||||
- 'matches': [{match, before, after, position}] - max 20
|
||||
- 'total_matches': Total found
|
||||
- 'truncated': If limited to 20
|
||||
|
||||
Without search_pattern (PAGINATION):
|
||||
- 'content': Page content
|
||||
- 'page': Current page
|
||||
- 'showing_lines': Range display
|
||||
- 'has_more': More pages available</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Find API endpoints in response
|
||||
<function=view_request>
|
||||
<parameter=request_id>123</parameter>
|
||||
<parameter=part>response</parameter>
|
||||
<parameter=search_pattern>/api/[a-zA-Z0-9._/-]+</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="send_request">
|
||||
<description>Send a simple HTTP request through proxy.</description>
|
||||
<parameters>
|
||||
<parameter name="method" type="string" required="true">
|
||||
<description>HTTP method (GET, POST, etc.)</description>
|
||||
</parameter>
|
||||
<parameter name="url" type="string" required="true">
|
||||
<description>Target URL</description>
|
||||
</parameter>
|
||||
<parameter name="headers" type="dict" required="false">
|
||||
<description>Headers as {"key": "value"}</description>
|
||||
</parameter>
|
||||
<parameter name="body" type="string" required="false">
|
||||
<description>Request body</description>
|
||||
</parameter>
|
||||
<parameter name="timeout" type="integer" required="false">
|
||||
<description>Request timeout</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
</tool>
|
||||
|
||||
<tool name="repeat_request">
|
||||
<description>Repeat an existing proxy request with modifications for pentesting.
|
||||
|
||||
PROPER WORKFLOW:
|
||||
1. Use browser_action to browse the target application
|
||||
2. Use list_requests() to see captured proxy traffic
|
||||
3. Use repeat_request() to modify and test specific requests
|
||||
|
||||
This mirrors real pentesting: browse → capture → modify → test</description>
|
||||
<parameters>
|
||||
<parameter name="request_id" type="string" required="true">
|
||||
<description>ID of the original request to repeat (from list_requests)</description>
|
||||
</parameter>
|
||||
<parameter name="modifications" type="dict" required="false">
|
||||
<description>Changes to apply to the original request:
|
||||
- "url": New URL or modify existing one
|
||||
- "params": Dict to update query parameters
|
||||
- "headers": Dict to add/update headers
|
||||
- "body": New request body (replaces original)
|
||||
- "cookies": Dict to add/update cookies</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response data with status, headers, body, timing, and request details</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Modify POST body payload
|
||||
<function=repeat_request>
|
||||
<parameter=request_id>req_789</parameter>
|
||||
<parameter=modifications>{"body": "{\"username\":\"admin\",\"password\":\"admin\"}"}</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="scope_rules">
|
||||
<description>Manage proxy scope patterns for domain/file filtering using Caido's scope system.</description>
|
||||
<parameters>
|
||||
<parameter name="action" type="string" required="true">
|
||||
<description>Scope action:
|
||||
- get: Get specific scope by ID or list all if no ID
|
||||
- update: Update existing scope (requires scope_id and scope_name)
|
||||
- list: List all available scopes
|
||||
- create: Create new scope (requires scope_name)
|
||||
- delete: Delete scope (requires scope_id)</description>
|
||||
</parameter>
|
||||
<parameter name="allowlist" type="list" required="false">
|
||||
<description>Domain patterns to include. Examples: ["*.example.com", "api.test.com"]</description>
|
||||
</parameter>
|
||||
<parameter name="denylist" type="list" required="false">
|
||||
<description>Patterns to exclude. Some common extensions:
|
||||
["*.gif", "*.jpg", "*.png", "*.css", "*.js", "*.ico", "*.svg", "*woff*", "*.ttf"]</description>
|
||||
</parameter>
|
||||
<parameter name="scope_id" type="string" required="false">
|
||||
<description>Specific scope ID to operate on (required for get, update, delete)</description>
|
||||
</parameter>
|
||||
<parameter name="scope_name" type="string" required="false">
|
||||
<description>Name for scope (required for create, update)</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Depending on action:
|
||||
- get: Single scope object or error
|
||||
- list: {"scopes": [...], "count": N}
|
||||
- create/update: {"scope": {...}, "message": "..."}
|
||||
- delete: {"message": "...", "deletedId": "..."}</description>
|
||||
</returns>
|
||||
<notes>
|
||||
- Empty allowlist = allow all domains
|
||||
- Denylist overrides allowlist
|
||||
- Glob patterns: * (any), ? (single), [abc] (one of), [a-z] (range), [^abc] (none of)
|
||||
- Each scope has unique ID and can be used with list_requests(scopeId=...)
|
||||
</notes>
|
||||
<examples>
|
||||
# Create API-only scope
|
||||
<function=scope_rules>
|
||||
<parameter=action>create</parameter>
|
||||
<parameter=scope_name>API Testing</parameter>
|
||||
<parameter=allowlist>["api.example.com", "*.api.com"]</parameter>
|
||||
<parameter=denylist>["*.gif", "*.jpg", "*.png", "*.css", "*.js"]</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="list_sitemap">
|
||||
<description>View hierarchical sitemap of discovered attack surface from proxied traffic.
|
||||
|
||||
Perfect for bug hunters to understand the application structure and identify
|
||||
interesting endpoints, directories, and entry points discovered during testing.</description>
|
||||
<parameters>
|
||||
<parameter name="scope_id" type="string" required="false">
|
||||
<description>Scope ID to filter sitemap entries (use scope_rules to get/create scope IDs)</description>
|
||||
</parameter>
|
||||
<parameter name="parent_id" type="string" required="false">
|
||||
<description>ID of parent entry to expand. If None, returns root domains.</description>
|
||||
</parameter>
|
||||
<parameter name="depth" type="string" required="false">
|
||||
<description>DIRECT: Only immediate children. ALL: All descendants recursively.</description>
|
||||
</parameter>
|
||||
<parameter name="page" type="integer" required="false">
|
||||
<description>Page number for pagination (30 entries per page)</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing:
|
||||
- 'entries': List of cleaned sitemap entries
|
||||
- 'page', 'total_pages', 'total_count': Pagination info
|
||||
- 'has_more': Whether more pages available
|
||||
- Each entry: id, kind, label, hasDescendants, request (method/path/status only)</description>
|
||||
</returns>
|
||||
<notes>
|
||||
Entry kinds:
|
||||
- DOMAIN: Root domains (example.com)
|
||||
- DIRECTORY: Path directories (/api/, /admin/)
|
||||
- REQUEST: Individual endpoints
|
||||
- REQUEST_BODY: POST/PUT body variations
|
||||
- REQUEST_QUERY: GET parameter variations
|
||||
|
||||
Check hasDescendants=true to identify entries worth expanding.
|
||||
Use parent_id from any entry to drill down into subdirectories.
|
||||
</notes>
|
||||
</tool>
|
||||
|
||||
<tool name="view_sitemap_entry">
|
||||
<description>Get detailed information about a specific sitemap entry and related requests.
|
||||
|
||||
Perfect for understanding what's been discovered under a specific directory
|
||||
or endpoint, including all related requests and response codes.</description>
|
||||
<parameters>
|
||||
<parameter name="entry_id" type="string" required="true">
|
||||
<description>ID of the sitemap entry to examine</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing:
|
||||
- 'entry': Complete entry details including metadata
|
||||
- Entry contains 'requests' with all related HTTP requests
|
||||
- Shows request methods, paths, response codes, timing</description>
|
||||
</returns>
|
||||
</tool>
|
||||
</tools>
|
||||
@@ -0,0 +1,785 @@
|
||||
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
|
||||
|
||||
|
||||
class ProxyManager:
|
||||
def __init__(self, auth_token: str | None = None):
|
||||
host = "127.0.0.1"
|
||||
port = os.getenv("CAIDO_PORT", "56789")
|
||||
self.base_url = f"http://{host}:{port}/graphql"
|
||||
self.proxies = {"http": f"http://{host}:{port}", "https": f"http://{host}:{port}"}
|
||||
self.auth_token = auth_token or os.getenv("CAIDO_API_TOKEN")
|
||||
self.transport = RequestsHTTPTransport(
|
||||
url=self.base_url, headers={"Authorization": f"Bearer {self.auth_token}"}
|
||||
)
|
||||
self.client = Client(transport=self.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.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.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.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.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.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.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.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.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.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.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:
|
||||
if _PROXY_MANAGER is None:
|
||||
return ProxyManager()
|
||||
return _PROXY_MANAGER
|
||||
Reference in New Issue
Block a user