mirror of https://github.com/aliasrobotics/cai.git
Add JS surface mapper tool to Web Pentester agent (#397)
add js mapper Co-authored-by: root <root@Unknown.localdomain>
This commit is contained in:
parent
e796efb100
commit
e38ac2b031
|
|
@ -14,6 +14,7 @@ from cai.agents.guardrails import get_security_guardrails
|
||||||
from cai.tools.reconnaissance.generic_linux_command import generic_linux_command
|
from cai.tools.reconnaissance.generic_linux_command import generic_linux_command
|
||||||
from cai.tools.reconnaissance.exec_code import execute_code
|
from cai.tools.reconnaissance.exec_code import execute_code
|
||||||
from cai.tools.web.headers import web_request_framework
|
from cai.tools.web.headers import web_request_framework
|
||||||
|
from cai.tools.web.js_surface_mapper import js_surface_mapper
|
||||||
|
|
||||||
# Optional OSINT search (requires PERPLEXITY_API_KEY)
|
# Optional OSINT search (requires PERPLEXITY_API_KEY)
|
||||||
from cai.tools.web.search_web import make_web_search_with_explanation
|
from cai.tools.web.search_web import make_web_search_with_explanation
|
||||||
|
|
@ -29,6 +30,7 @@ tools = [
|
||||||
generic_linux_command, # shell one-liners (httpie/curl/waybackurls/etc if installed)
|
generic_linux_command, # shell one-liners (httpie/curl/waybackurls/etc if installed)
|
||||||
execute_code, # light parsing/diffing/payload crafting
|
execute_code, # light parsing/diffing/payload crafting
|
||||||
web_request_framework, # HTTP request + response/header security analysis
|
web_request_framework, # HTTP request + response/header security analysis
|
||||||
|
js_surface_mapper, # JS asset surface extraction (endpoints/ops/ws)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Conditional: add web search helper when available
|
# Conditional: add web search helper when available
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,334 @@
|
||||||
|
"""
|
||||||
|
JS Surface Mapper - extract high-signal attack surface hints from JS assets.
|
||||||
|
|
||||||
|
Baseline functionality (framework-agnostic):
|
||||||
|
- Fetch entry HTML pages and collect JS asset URLs
|
||||||
|
- Fetch JS assets (bounded)
|
||||||
|
- Extract API endpoints, full URLs, GraphQL hints, WS/SSE endpoints
|
||||||
|
- Optionally fetch sourcemaps and extract from sourcesContent
|
||||||
|
|
||||||
|
This tool is intentionally deterministic and bounded to avoid crawling noise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import requests # type: ignore
|
||||||
|
|
||||||
|
from cai.sdk.agents import function_tool
|
||||||
|
|
||||||
|
|
||||||
|
_FULL_URL_RE = re.compile(r"https?://[^\s\"'<>\\)]+")
|
||||||
|
_WS_URL_RE = re.compile(r"wss?://[^\s\"'<>\\)]+")
|
||||||
|
_GQL_ENDPOINT_RE = re.compile(r"/graphql\b|/gql\b", re.IGNORECASE)
|
||||||
|
_GQL_OPNAME_RE = re.compile(r"operationName\s*[:=]\s*[\"']([A-Za-z0-9_]{2,})[\"']")
|
||||||
|
_GQL_OP_RE = re.compile(r"\b(query|mutation|subscription)\s+([A-Za-z0-9_]{2,})")
|
||||||
|
_PERSISTED_HASH_RE = re.compile(r"sha256Hash\s*[:=]\s*[\"']([a-fA-F0-9]{16,64})[\"']")
|
||||||
|
|
||||||
|
# Broad-but-targeted path patterns for endpoints
|
||||||
|
_PATH_ENDPOINT_RE = re.compile(
|
||||||
|
r"(?<![A-Za-z0-9_])/(?:"
|
||||||
|
r"api|graphql|gql|v\d+|admin|internal|export|download|uploads|files|"
|
||||||
|
r"report|reports|billing|oauth|auth|login|logout|session|sessions|"
|
||||||
|
r"token|tokens|users|user|account|accounts|tenant|tenants|org|orgs|"
|
||||||
|
r"organization|organizations|project|projects|team|teams|workspace|workspaces|"
|
||||||
|
r"invoice|invoices|payment|checkout|order|orders|cart|carts|subscription|subscriptions|"
|
||||||
|
r"feature|features|flag|flags|debug|preview|staging"
|
||||||
|
r")(?:[A-Za-z0-9_\-./?=&%]*)"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SOURCE_MAP_RE = re.compile(r"^\s*//#\s*sourceMappingURL\s*=\s*(\S+)\s*$", re.MULTILINE)
|
||||||
|
|
||||||
|
_HIGH_VALUE_STRINGS = [
|
||||||
|
"admin", "entitlement", "featureflag", "feature_flag", "flag", "debug",
|
||||||
|
"internal", "staging", "preview", "billing", "invoice", "payment", "export",
|
||||||
|
"report", "impersonate", "impersonation", "role", "permission", "rbac",
|
||||||
|
"tenant", "organization", "workspace",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ExtractionResult:
|
||||||
|
origins: Set[str] = field(default_factory=set)
|
||||||
|
endpoints: Set[str] = field(default_factory=set)
|
||||||
|
graphql_endpoints: Set[str] = field(default_factory=set)
|
||||||
|
graphql_ops: Set[str] = field(default_factory=set)
|
||||||
|
persisted_hashes: Set[str] = field(default_factory=set)
|
||||||
|
ws_endpoints: Set[str] = field(default_factory=set)
|
||||||
|
high_value: Set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
|
||||||
|
class _AssetHTMLParser(HTMLParser):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.script_srcs: List[str] = []
|
||||||
|
self.inline_scripts: List[str] = []
|
||||||
|
self._in_script: bool = False
|
||||||
|
self._current_inline: List[str] = []
|
||||||
|
self.link_hrefs: List[str] = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||||
|
attrs_dict = {k.lower(): (v or "") for k, v in attrs}
|
||||||
|
if tag.lower() == "script":
|
||||||
|
src = attrs_dict.get("src", "").strip()
|
||||||
|
if src:
|
||||||
|
self.script_srcs.append(src)
|
||||||
|
else:
|
||||||
|
self._in_script = True
|
||||||
|
self._current_inline = []
|
||||||
|
elif tag.lower() == "link":
|
||||||
|
rel = attrs_dict.get("rel", "").lower()
|
||||||
|
href = attrs_dict.get("href", "").strip()
|
||||||
|
as_attr = attrs_dict.get("as", "").lower()
|
||||||
|
if href and (rel in ("modulepreload", "preload") or as_attr == "script"):
|
||||||
|
self.link_hrefs.append(href)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
if tag.lower() == "script" and self._in_script:
|
||||||
|
content = "".join(self._current_inline).strip()
|
||||||
|
if content:
|
||||||
|
self.inline_scripts.append(content)
|
||||||
|
self._in_script = False
|
||||||
|
self._current_inline = []
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if self._in_script and data:
|
||||||
|
self._current_inline.append(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_base_url(base_url: str) -> str:
|
||||||
|
base_url = (base_url or "").strip()
|
||||||
|
if not base_url:
|
||||||
|
return ""
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if not parsed.scheme:
|
||||||
|
base_url = "http://" + base_url
|
||||||
|
return base_url.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _origin(url: str) -> str:
|
||||||
|
p = urlparse(url)
|
||||||
|
if not p.scheme or not p.netloc:
|
||||||
|
return ""
|
||||||
|
return f"{p.scheme}://{p.netloc}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_text(url: str, headers: Optional[Dict[str, str]], cookies: Optional[Dict[str, str]],
|
||||||
|
timeout: int, max_bytes: int) -> Tuple[str, Optional[str]]:
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=headers, cookies=cookies, timeout=timeout, verify=False, stream=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = bytearray()
|
||||||
|
for chunk in resp.iter_content(chunk_size=16384):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
data.extend(chunk)
|
||||||
|
if len(data) >= max_bytes:
|
||||||
|
break
|
||||||
|
# Best-effort decode
|
||||||
|
text = data.decode(errors="replace")
|
||||||
|
return text, None
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
return "", f"{url} -> {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_from_text(text: str, source_label: str, base_origin: str) -> _ExtractionResult:
|
||||||
|
result = _ExtractionResult()
|
||||||
|
if not text:
|
||||||
|
return result
|
||||||
|
|
||||||
|
for url in _FULL_URL_RE.findall(text):
|
||||||
|
result.origins.add(_origin(url))
|
||||||
|
if _GQL_ENDPOINT_RE.search(url):
|
||||||
|
result.graphql_endpoints.add(url)
|
||||||
|
|
||||||
|
for url in _WS_URL_RE.findall(text):
|
||||||
|
result.ws_endpoints.add(url)
|
||||||
|
result.origins.add(_origin(url))
|
||||||
|
|
||||||
|
for path in _PATH_ENDPOINT_RE.findall(text):
|
||||||
|
if path.startswith("/"):
|
||||||
|
result.endpoints.add(path)
|
||||||
|
if _GQL_ENDPOINT_RE.search(path):
|
||||||
|
result.graphql_endpoints.add(urljoin(base_origin + "/", path))
|
||||||
|
|
||||||
|
for op in _GQL_OPNAME_RE.findall(text):
|
||||||
|
result.graphql_ops.add(op)
|
||||||
|
for _, op in _GQL_OP_RE.findall(text):
|
||||||
|
result.graphql_ops.add(op)
|
||||||
|
|
||||||
|
for h in _PERSISTED_HASH_RE.findall(text):
|
||||||
|
result.persisted_hashes.add(h)
|
||||||
|
|
||||||
|
lowered = text.lower()
|
||||||
|
for s in _HIGH_VALUE_STRINGS:
|
||||||
|
if s in lowered:
|
||||||
|
result.high_value.add(s)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_result(target: _ExtractionResult, src: _ExtractionResult) -> None:
|
||||||
|
target.origins |= src.origins
|
||||||
|
target.endpoints |= src.endpoints
|
||||||
|
target.graphql_endpoints |= src.graphql_endpoints
|
||||||
|
target.graphql_ops |= src.graphql_ops
|
||||||
|
target.persisted_hashes |= src.persisted_hashes
|
||||||
|
target.ws_endpoints |= src.ws_endpoints
|
||||||
|
target.high_value |= src.high_value
|
||||||
|
|
||||||
|
|
||||||
|
@function_tool(strict_mode=False)
|
||||||
|
def js_surface_mapper( # pylint: disable=too-many-arguments,too-many-locals
|
||||||
|
base_url: str,
|
||||||
|
entry_paths: Optional[List[str]] = None,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
cookies: Optional[Dict[str, str]] = None,
|
||||||
|
same_origin_only: bool = True,
|
||||||
|
max_assets: int = 30,
|
||||||
|
max_bytes_per_asset: int = 2_000_000,
|
||||||
|
include_sourcemaps: bool = False,
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Extract JS-derived attack surface hints from a web application.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Base URL of the app (e.g., https://example.com)
|
||||||
|
entry_paths: HTML entry paths to parse (default ["/"])
|
||||||
|
headers: Optional request headers (auth)
|
||||||
|
cookies: Optional request cookies (auth)
|
||||||
|
same_origin_only: Only fetch JS from base origin (default True)
|
||||||
|
max_assets: Cap JS assets fetched (default 30)
|
||||||
|
max_bytes_per_asset: Cap bytes per asset (default 2,000,000)
|
||||||
|
include_sourcemaps: Fetch and parse sourcemaps (default False)
|
||||||
|
timeout: Request timeout (seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with extracted surface hints and evidence.
|
||||||
|
"""
|
||||||
|
base_url = _normalize_base_url(base_url)
|
||||||
|
if not base_url:
|
||||||
|
return json.dumps({"error": "base_url is required"}, ensure_ascii=True)
|
||||||
|
|
||||||
|
base_origin = _origin(base_url)
|
||||||
|
entry_paths = entry_paths or ["/"]
|
||||||
|
|
||||||
|
assets: List[str] = []
|
||||||
|
inline_sources: List[Tuple[str, str]] = []
|
||||||
|
errors: List[str] = []
|
||||||
|
evidence: Dict[str, Set[str]] = {}
|
||||||
|
sourcemaps_info: List[Dict[str, object]] = []
|
||||||
|
|
||||||
|
# Fetch entry HTML pages
|
||||||
|
for path in entry_paths:
|
||||||
|
entry_url = path if path.startswith("http") else urljoin(base_url + "/", path.lstrip("/"))
|
||||||
|
html, err = _fetch_text(entry_url, headers, cookies, timeout, max_bytes_per_asset)
|
||||||
|
if err:
|
||||||
|
errors.append(err)
|
||||||
|
continue
|
||||||
|
parser = _AssetHTMLParser()
|
||||||
|
parser.feed(html)
|
||||||
|
|
||||||
|
# Inline script content
|
||||||
|
for idx, script in enumerate(parser.inline_scripts):
|
||||||
|
inline_sources.append((f"{entry_url}#inline{idx+1}", script))
|
||||||
|
|
||||||
|
# External JS assets
|
||||||
|
for src in parser.script_srcs + parser.link_hrefs:
|
||||||
|
full = src if src.startswith("http") else urljoin(entry_url, src)
|
||||||
|
assets.append(full)
|
||||||
|
|
||||||
|
# De-dup assets and apply limits
|
||||||
|
seen: Set[str] = set()
|
||||||
|
dedup_assets: List[str] = []
|
||||||
|
for a in assets:
|
||||||
|
if a in seen:
|
||||||
|
continue
|
||||||
|
seen.add(a)
|
||||||
|
if same_origin_only and _origin(a) and _origin(a) != base_origin:
|
||||||
|
continue
|
||||||
|
dedup_assets.append(a)
|
||||||
|
if len(dedup_assets) >= max_assets:
|
||||||
|
break
|
||||||
|
|
||||||
|
extraction = _ExtractionResult(origins={base_origin})
|
||||||
|
|
||||||
|
# Extract from inline scripts
|
||||||
|
for label, content in inline_sources:
|
||||||
|
res = _extract_from_text(content, label, base_origin)
|
||||||
|
_merge_result(extraction, res)
|
||||||
|
|
||||||
|
# Fetch JS assets and extract
|
||||||
|
for asset_url in dedup_assets:
|
||||||
|
js, err = _fetch_text(asset_url, headers, cookies, timeout, max_bytes_per_asset)
|
||||||
|
if err:
|
||||||
|
errors.append(err)
|
||||||
|
continue
|
||||||
|
res = _extract_from_text(js, asset_url, base_origin)
|
||||||
|
_merge_result(extraction, res)
|
||||||
|
|
||||||
|
# Evidence mapping
|
||||||
|
for ep in res.endpoints:
|
||||||
|
evidence.setdefault(ep, set()).add(asset_url)
|
||||||
|
for op in res.graphql_ops:
|
||||||
|
evidence.setdefault(f"gql_op:{op}", set()).add(asset_url)
|
||||||
|
for g in res.graphql_endpoints:
|
||||||
|
evidence.setdefault(f"gql_endpoint:{g}", set()).add(asset_url)
|
||||||
|
for w in res.ws_endpoints:
|
||||||
|
evidence.setdefault(f"ws:{w}", set()).add(asset_url)
|
||||||
|
|
||||||
|
# Sourcemap discovery
|
||||||
|
if include_sourcemaps:
|
||||||
|
for sm in _SOURCE_MAP_RE.findall(js):
|
||||||
|
sm_url = sm if sm.startswith("http") else urljoin(asset_url, sm)
|
||||||
|
sm_text, sm_err = _fetch_text(sm_url, headers, cookies, timeout, max_bytes_per_asset)
|
||||||
|
if sm_err:
|
||||||
|
errors.append(sm_err)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sm_json = json.loads(sm_text)
|
||||||
|
sources_content = sm_json.get("sourcesContent") or []
|
||||||
|
sourcemaps_info.append({
|
||||||
|
"url": sm_url,
|
||||||
|
"sourcesContent": bool(sources_content),
|
||||||
|
"source_count": len(sm_json.get("sources", []) or []),
|
||||||
|
})
|
||||||
|
# Extract from sourcesContent (bounded)
|
||||||
|
for idx, src in enumerate(sources_content[:50]):
|
||||||
|
res_map = _extract_from_text(src or "", f"{sm_url}#src{idx+1}", base_origin)
|
||||||
|
_merge_result(extraction, res_map)
|
||||||
|
for ep in res_map.endpoints:
|
||||||
|
evidence.setdefault(ep, set()).add(sm_url)
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
errors.append(f"{sm_url} -> sourcemap parse error: {exc}")
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
endpoints_by_origin: Dict[str, List[str]] = {}
|
||||||
|
for ep in sorted(extraction.endpoints):
|
||||||
|
endpoints_by_origin.setdefault(base_origin, []).append(ep)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"base_url": base_url,
|
||||||
|
"origins": sorted(o for o in extraction.origins if o),
|
||||||
|
"assets_fetched": dedup_assets,
|
||||||
|
"endpoints": endpoints_by_origin,
|
||||||
|
"graphql": {
|
||||||
|
"endpoints": sorted(extraction.graphql_endpoints),
|
||||||
|
"operation_names": sorted(extraction.graphql_ops),
|
||||||
|
"persisted_query_hints": sorted(extraction.persisted_hashes),
|
||||||
|
},
|
||||||
|
"ws_sse": sorted(extraction.ws_endpoints),
|
||||||
|
"sourcemaps": sourcemaps_info,
|
||||||
|
"high_value_strings": sorted(extraction.high_value),
|
||||||
|
"evidence": {k: sorted(list(v))[:3] for k, v in evidence.items()},
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(output, ensure_ascii=True)
|
||||||
Loading…
Reference in New Issue