Files
rengine/shared/services/scan_resolve.py
T
Yogesh OjhaandClaude Opus 5 d7c8b6e01f feat(vulns): keep listening for an out-of-band callback, and let a self-hosted server be reached
Blind injection is invisible unless the target calls back, and reNgine had the
switch and the server field without either of the two things that make them
work.

nuclei's -interactions-cooldown-period defaults to 5 seconds: it stops polling
five seconds after its last request, so a callback a queue, a cron or a report
generator fires minutes later is never heard. oast_wait_minutes (default 5) is
the one operator-facing number and drives both that and -interactions-eviction,
because a request evicted from nuclei's cache before its callback arrives leaves
the callback with nothing to attribute it to — eviction is always the wait plus
slack.

A self-hosted server is normally token-gated and -interactsh-token was never
passed, so a configured server silently fell back to the public one and the
callbacks went somewhere the operator does not own. The token is an
APIProvider.INTERACTSH row — encrypted at rest and masked on read like every
other key — rather than a stage field, because an OAST server is instance
infrastructure, not a per-engine choice. _oast_server() reduces whatever is
typed to a bare host: nuclei wants the host, and a scheme or trailing path makes
it register nowhere and fall back without saying so.

redact_command now matches on how a flag ends rather than on the whole word.
-interactsh-token was redacted only by the accident of the pattern matching its
-token suffix, while nuclei's own short form -itoken wrote the secret straight
into scan_commands.

What this deliberately is not: reNgine owning an interactsh session of its own —
registering a correlation id, decrypting polled interactions and injecting its
own payloads on the probe path. That is a package the size of connectors/, and
the only way to hear a callback for a request nuclei did not send.

20 tests. Two mutations survived the first cut — an empty token row becoming
`-interactsh-token ""`, and the token being read for a run that will not use it —
because the tests built options directly instead of going through the scanner.
Both are covered and dead now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013dKUV96WkQQETZBMoAG98C
2026-09-11 09:13:57 +05:30

508 lines
18 KiB
Python

from __future__ import annotations
import base64
import re
from collections.abc import Iterable
from typing import TYPE_CHECKING, ClassVar
from pydantic import BaseModel, Field, PrivateAttr, ValidationError
from shared.definitions.constants import (
DEFAULT_GLOBAL_THREADS,
MAX_RATE,
MAX_THREADS,
MAX_TIMEOUT,
)
from shared.definitions.tools import parse_tool_args
if TYPE_CHECKING:
from fastapi import HTTPException
MASK = "••••••••"
SECRET_FIELDS = {
"bearer_token",
"basic_password",
"header_value",
"cookie_value",
"api_key_value",
}
_SENSITIVE_HEADER = re.compile(
r"^(authorization|cookie|x-api-key)$|token|secret", re.IGNORECASE
)
PROXY_CREDS_RE = re.compile(r"(\w+://)[^/\s]*@")
_HEADER_VALUE = re.compile(
r'(-H\s+["\']?(?:authorization|cookie|x-api-key|[\w-]*(?:token|secret)[\w-]*)'
r'\s*:\s*)([^"\'\n]+?)(?=["\']|\s+-|\s*$)',
re.IGNORECASE,
)
# the flag NAME may carry a prefix (-interactsh-token, -itoken): match on how it ends,
# not on the whole word, or a tool's own spelling decides whether a secret is stored
_CRED_FLAG = re.compile(
r"((?:-{1,2}[\w-]*?(?:api[-_]?key|key|token|password|passwd|pass|secret))[ =])(\S+)",
re.IGNORECASE,
)
_UNSAFE_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
_CTRL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
def _bad(detail: str) -> HTTPException:
from fastapi import HTTPException, status # noqa: PLC0415
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
def _reject_ctrl(label: str, value) -> None:
if isinstance(value, str) and _CTRL_CHARS.search(value):
msg = f"{label} must not contain control characters (CR/LF/NUL)."
raise _bad(msg)
def _mask_auth(auth: dict) -> dict:
masked = dict(auth or {})
for field in SECRET_FIELDS:
if masked.get(field):
masked[field] = MASK
return masked
def _mask_headers(headers: list) -> list:
out = []
for h in headers or []:
name = h.get("name", "")
value = h.get("value", "")
if value and _SENSITIVE_HEADER.search(name):
value = MASK
out.append({"name": name, "value": value})
return out
def mask_proxy_url(value: str | None) -> str | None:
return PROXY_CREDS_RE.sub(rf"\1{MASK}@", value) if value else value
def secret_runs(text: str | None) -> list[str]:
"""The values redact_command would mask, in the order they appear.
Masking and restoring must agree on what a secret is, so both read this.
"""
if not text:
return []
found: list[tuple[int, str]] = []
for pattern, group in ((_CRED_FLAG, 2), (_HEADER_VALUE, 2), (PROXY_CREDS_RE, 0)):
for match in pattern.finditer(text):
if group and match.group(group):
found.append((match.start(group), match.group(group)))
return [value for _, value in sorted(found)]
def redact_command(command: str) -> str:
"""Strip NUL/control bytes + proxy creds + sensitive header/flag values for safe storage."""
safe = _UNSAFE_CTRL.sub("", command or "")
safe = PROXY_CREDS_RE.sub(rf"\1{MASK}@", safe)
safe = _HEADER_VALUE.sub(rf"\1{MASK}", safe)
return _CRED_FLAG.sub(rf"\1{MASK}", safe)
def seal_headers(headers: dict[str, str] | None) -> dict[str, str]:
"""Encrypt header values before they are persisted with a scan."""
from shared.utils.crypto import encrypt_secret # noqa: PLC0415
return {
name: encrypt_secret(value or "") for name, value in (headers or {}).items()
}
def unseal_headers(headers: dict[str, str] | None) -> dict[str, str]:
"""Read them back, tolerating rows written before they were sealed."""
from shared.utils.crypto import try_decrypt # noqa: PLC0415
out: dict[str, str] = {}
for name, value in (headers or {}).items():
plain = try_decrypt(value) if value else ""
out[name] = plain if plain is not None else value
return out
MIN_SECRET_LENGTH = 8
def redact_secrets(text: str | None, secrets: Iterable[str]) -> str | None:
"""Mask credential values the scan itself injected, wherever a tool echoed them back.
Stored proof (a raw request, a curl command) carries whatever headers the scan
context supplied. nuclei masks the header names it knows; a custom one is its own
business, so we mask by value instead of by name.
"""
if not text:
return text
for secret in secrets:
if secret and len(secret) >= MIN_SECRET_LENGTH:
text = text.replace(secret, MASK)
return text
def _auth_summary(auth: dict, extra_headers: list) -> str: # noqa: PLR0911
auth = auth or {}
auth_type = auth.get("auth_type", "none")
if auth_type == "bearer":
return "Bearer ••••"
if auth_type == "basic":
user = auth.get("basic_username") or ""
return f"Basic ({user})" if user else "Basic"
if auth_type == "cookie":
return "Cookie ••••"
if auth_type == "header":
return auth.get("header_name") or "Header"
if auth_type == "api_key":
return auth.get("api_key_name") or "API Key"
n = len(extra_headers or [])
if n:
return f"{n} header{'s' if n != 1 else ''}"
return "None"
def resolve_headers(ctx_or_auth, extra_headers: list | None = None) -> dict[str, str]:
def _get(key: str):
if isinstance(ctx_or_auth, dict):
return ctx_or_auth.get(key)
return getattr(ctx_or_auth, key, None)
auth_type = _get("auth_type") or "none"
headers: dict[str, str] = {}
if auth_type == "bearer":
token = _get("bearer_token")
if token:
headers["Authorization"] = f"Bearer {token}"
elif auth_type == "basic":
user = _get("basic_username") or ""
password = _get("basic_password") or ""
raw = f"{user}:{password}".encode()
headers["Authorization"] = "Basic " + base64.b64encode(raw).decode()
elif auth_type == "header":
name = _get("header_name")
value = _get("header_value")
if name:
headers[name] = value or ""
elif auth_type == "cookie":
cookie = _get("cookie_value")
if cookie:
headers["Cookie"] = cookie
elif auth_type == "api_key":
name = _get("api_key_name")
value = _get("api_key_value")
if name:
headers[name] = value or ""
lower_map = {k.lower(): k for k in headers}
for h in extra_headers or []:
if isinstance(h, dict):
name = h.get("name")
value = h.get("value", "")
else:
name = getattr(h, "name", None)
value = getattr(h, "value", "")
if not name:
continue
existing = lower_map.get(name.lower())
if existing is not None:
del headers[existing]
headers[name] = value
lower_map[name.lower()] = name
return headers
def _clamp(value, lo, hi):
return max(lo, min(hi, value))
class _NeutralContext:
auth: ClassVar[dict] = {"auth_type": "none"}
extra_headers: ClassVar[list] = []
global_rate_limit_override = None
per_tool_rate_overrides: ClassVar[dict] = {}
thread_multiplier = 1.0
timeout_multiplier = 1.0
excluded_subdomains: ClassVar[list] = []
excluded_paths: ClassVar[list] = []
excluded_ips: ClassVar[list] = []
included_subdomains: ClassVar[list] = []
follow_redirects_override = None
http_protocol = "both"
compare_baseline_scan_id = None
scan_only_new_assets = False
class ResolvedScanConfig(BaseModel):
target_value: str
target_type: str
headers: dict[str, str] = Field(default_factory=dict)
per_tool_rate_limits: dict[str, int] = Field(default_factory=dict)
global_rate_limit_ceiling: int | None = None
global_threads: int = DEFAULT_GLOBAL_THREADS
thread_multiplier: float = 1.0
timeout_multiplier: float = 1.0
stages: dict[str, dict] = Field(default_factory=dict)
excluded_subdomains: list[str] = Field(default_factory=list)
excluded_paths: list[str] = Field(default_factory=list)
excluded_ips: list[str] = Field(default_factory=list)
included_subdomains: list[str] = Field(default_factory=list)
follow_redirects: bool | None = None
http_protocol: str = "both"
global_http_crawl: bool = True
intensity: str = "normal"
proxy_url: str | None = None
tool_options: dict[str, str] = Field(default_factory=dict)
overrides: dict[str, dict] = Field(default_factory=dict)
seed_assets: list[dict] = Field(default_factory=list)
_auth_header_names: list[str] = PrivateAttr(default_factory=list)
def tool_args(self, tool: str) -> list[str]:
return parse_tool_args((self.tool_options or {}).get(tool, ""))
def stage(self, name: str) -> dict:
return self.stages.get(name) or {}
def auth_headers(self) -> dict[str, str]:
names = {n.lower() for n in self._auth_header_names}
return {k: v for k, v in self.headers.items() if k.lower() in names}
def headers_without_auth(self) -> dict[str, str]:
names = {n.lower() for n in self._auth_header_names}
return {k: v for k, v in self.headers.items() if k.lower() not in names}
def __repr__(self) -> str:
proxy = "<set>" if self.proxy_url else "None"
return (
f"ResolvedScanConfig(target_value={self.target_value!r}, "
f"target_type={self.target_type!r}, "
f"headers=<{len(self.headers)} redacted>, "
f"global_threads={self.global_threads}, "
f"http_protocol={self.http_protocol!r}, intensity={self.intensity!r}, "
f"proxy={proxy})"
)
__str__ = __repr__
def _check_baseline_deferred(compare_baseline_scan_id, scan_only_new_assets) -> None:
if compare_baseline_scan_id is not None or scan_only_new_assets:
from fastapi import HTTPException, status # noqa: PLC0415
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Baseline comparison is not available yet.",
)
def _ctx_get(ctx, key, default=None):
if isinstance(ctx, dict):
return ctx.get(key, default)
return getattr(ctx, key, default)
def _build_headers(engine, ctx) -> tuple[dict[str, str], list[str]]:
headers: dict[str, str] = {}
for line in engine.global_headers or []:
if not isinstance(line, str) or ":" not in line:
continue
name, value = line.split(":", 1)
name = name.strip()
value = value.strip()
if name:
headers[name] = value
auth = _ctx_get(ctx, "auth") or {"auth_type": "none"}
extra_headers = _ctx_get(ctx, "extra_headers") or []
ctx_headers = resolve_headers(auth, extra_headers)
# the credentials alone, so a control request can be sent without exactly them
auth_header_names: list[str] = list(resolve_headers(auth))
lower_map = {k.lower(): k for k in headers}
for name, value in ctx_headers.items():
existing = lower_map.get(name.lower())
if existing is not None:
del headers[existing]
headers[name] = value
lower_map[name.lower()] = name
for k, v in headers.items():
_reject_ctrl("Header name", k)
_reject_ctrl("Header value", v)
return headers, auth_header_names
def _resolve_rate(
base: int, tool: str | None, overrides: dict, ceiling: int | None
) -> int:
value = overrides.get(tool, base) if tool else base
if ceiling is not None:
value = min(value, ceiling)
return _clamp(int(value), 1, MAX_RATE)
def _assert_flags_preserved(stage: str, original: dict, scaled: dict) -> None:
for key, value in original.items():
if isinstance(value, bool) and scaled[key] != value:
msg = f"Resolved enable flag {stage}.{key} diverged from engine."
raise RuntimeError(msg)
def validate_overrides(overrides: dict | None) -> dict[str, dict]:
"""Keep only stage keys that exist and values their own config model accepts."""
from stages.registry import stage_by_name # noqa: PLC0415
if not overrides:
return {}
specs = stage_by_name()
clean: dict[str, dict] = {}
for name, authored in overrides.items():
spec = specs.get(name)
values = authored
if spec is not None and (spec.catalog_hidden or spec.always_on):
# the server decides this one; recording an override that had no effect
# would put a lie in the run's audit trail
values = (
{k: v for k, v in values.items() if k != "enabled"}
if isinstance(values, dict)
else values
)
if spec.catalog_hidden or not values:
continue
if spec is None or not isinstance(values, dict):
msg = f"Unknown stage {name!r} in run overrides."
raise _bad(msg)
allowed = set(spec.config_model.model_fields)
unknown = sorted(set(values) - allowed)
if unknown:
msg = f"{name} has no setting named {unknown[0]!r}."
raise _bad(msg)
try:
spec.config_model(**{**spec.defaults, **values})
except ValidationError as exc:
# pydantic's first line only counts the errors; the reason is in the entries
first = exc.errors()[0]
field = ".".join(str(part) for part in first.get("loc") or ())
reason = str(first.get("msg", "")).removeprefix("Value error, ")
where = f"{name}.{field}" if field else name
raise _bad(f"{where}: {reason}"[:300]) from exc
except Exception as exc:
msg = f"{name}: {str(exc).splitlines()[0][:200]}"
raise _bad(msg) from exc
clean[name] = dict(values)
return clean
def merge_engine_context(
engine,
context,
target_value: str,
target_type: str,
proxy_url: str | None = None,
overrides: dict | None = None,
intensity: str | None = None,
) -> ResolvedScanConfig:
from shared.enums.scan import INTENSITIES, Intensity # noqa: PLC0415
from stages.config import Scale # noqa: PLC0415
from stages.registry import stages as stage_specs # noqa: PLC0415
run_intensity = intensity or engine.intensity
if run_intensity not in INTENSITIES:
msg = f"intensity must be one of {sorted(INTENSITIES)}."
raise _bad(msg)
passive = run_intensity == Intensity.PASSIVE.value
ctx = context if context is not None else _NeutralContext()
_check_baseline_deferred(
_ctx_get(ctx, "compare_baseline_scan_id"),
_ctx_get(ctx, "scan_only_new_assets"),
)
thread_mult = float(_ctx_get(ctx, "thread_multiplier", 1.0))
timeout_mult = float(_ctx_get(ctx, "timeout_multiplier", 1.0))
rate_overrides = _ctx_get(ctx, "per_tool_rate_overrides") or {}
global_rate_limit_ceiling = _ctx_get(ctx, "global_rate_limit_override")
headers, auth_header_names = _build_headers(engine, ctx)
global_threads = _clamp(round(engine.global_threads * thread_mult), 1, MAX_THREADS)
stored = engine.stages or {}
run_overrides = validate_overrides(overrides)
stages: dict[str, dict] = {}
per_tool_rate_limits: dict[str, int] = {}
for spec in stage_specs():
# not a choice, so no stored document may switch it off either
authored = (
{}
if spec.catalog_hidden
else {
**(stored.get(spec.name) or {}),
**(run_overrides.get(spec.name) or {}),
}
)
if spec.always_on:
authored["enabled"] = True
config = spec.config_model(**authored)
values = config.model_dump()
for name, (scale, tool) in spec.config_model.scaled_fields().items():
base = values[name]
if scale is Scale.THREADS:
values[name] = _clamp(int(base * thread_mult) or 1, 1, MAX_THREADS)
elif scale is Scale.TIMEOUT:
values[name] = _clamp(int(base * timeout_mult) or 1, 1, MAX_TIMEOUT)
elif scale is Scale.RATE:
values[name] = _resolve_rate(
base, tool, rate_overrides, global_rate_limit_ceiling
)
per_tool_rate_limits[tool] = values[name]
_assert_flags_preserved(spec.name, config.model_dump(), values)
if passive and spec.touches_target:
values["enabled"] = False
stages[spec.name] = values
excluded_subdomains = list(_ctx_get(ctx, "excluded_subdomains") or [])
excluded_paths = list(_ctx_get(ctx, "excluded_paths") or [])
excluded_ips = list(_ctx_get(ctx, "excluded_ips") or [])
included_subdomains = list(_ctx_get(ctx, "included_subdomains") or [])
follow_redirects_override = _ctx_get(ctx, "follow_redirects_override")
follow_redirects = (
follow_redirects_override if follow_redirects_override is not None else None
)
http_protocol = _ctx_get(ctx, "http_protocol", "both") or "both"
config = ResolvedScanConfig(
target_value=target_value,
target_type=target_type,
headers=headers,
per_tool_rate_limits=per_tool_rate_limits,
global_rate_limit_ceiling=global_rate_limit_ceiling,
global_threads=global_threads,
thread_multiplier=thread_mult,
timeout_multiplier=timeout_mult,
stages=stages,
excluded_subdomains=excluded_subdomains,
excluded_paths=excluded_paths,
excluded_ips=excluded_ips,
included_subdomains=included_subdomains,
follow_redirects=follow_redirects,
http_protocol=http_protocol,
global_http_crawl=engine.global_http_crawl,
intensity=run_intensity,
tool_options=dict(getattr(engine, "tool_options", None) or {}),
overrides=run_overrides,
)
config._auth_header_names = auth_header_names
config.proxy_url = proxy_url
return config