Files
rengine/api/app/services/scan_engine/effects.py
T
Yogesh OjhaandClaude Fable 5.1 f236dce28e fix(engine): WAF detection joins the transport, the preview states the target rate
wafw00f's shard concurrency comes from the httpx transport at 1/25, so
intensity and the context multipliers move it. The launch preview sums
the weighted per-stage rate the footprint shows instead of listing the
unweighted per-tool ceilings, and a passive-capable stage reports no
rate under passive. The frontend mirror of the resolved config carries
transports instead of two fields the backend no longer sends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq4xYJbgW5euUa9sCWBKFw
2026-09-14 12:49:28 +05:30

154 lines
5.6 KiB
Python

from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.scan_engine.validation import (
_validate_intensity,
_validate_stages,
)
from shared.definitions.launch import ASSET_KIND_LABELS, seed_produces
from shared.enums.scan import Intensity
from shared.enums.target import TargetType
from shared.models.scan_context import ScanContext
from shared.models.scan_engine import EnginePreviewResult, PreviewResolved
from shared.models.scan_preview import PreviewPhase, PreviewTool, PreviewToolStatus
from shared.services.scan_resolve import merge_engine_context
from stages.registry import ordered_levels, phases
def stage_effects(
resolved, configured: set[str]
) -> tuple[list[PreviewPhase], list[str]]:
warnings: list[str] = []
statuses = _stage_statuses(resolved, configured, warnings)
out = [
PreviewPhase(
phase=phase_name,
label=phase_name.replace("_", " ").title(),
tools=[statuses[spec.name] for spec in specs],
)
for phase_name, specs in phases()
]
return out, warnings
def _stage_statuses(
resolved, configured: set[str], warnings: list[str]
) -> dict[str, PreviewTool]:
"""One status per stage, walking levels."""
available = set(seed_produces(resolved.target_type))
out: dict[str, PreviewTool] = {}
for level in ordered_levels():
produced: set[str] = set()
for spec in level:
tool = _stage_status(spec, resolved, configured, available, warnings)
out[spec.name] = tool
if tool.status is PreviewToolStatus.WILL_RUN:
produced |= spec.produces
available |= produced
return out
def _stage_status(
spec, resolved, configured: set[str], available: set[str], warnings: list[str]
) -> PreviewTool:
values = resolved.stage(spec.name)
def _skip(status: PreviewToolStatus, reason: str) -> PreviewTool:
return PreviewTool(
capability=spec.name, label=spec.title, status=status, reason=reason
)
if not values.get("enabled", True):
reason = (
"Skipped at passive intensity."
if resolved.intensity == Intensity.PASSIVE.value
and spec.touches_target
and not spec.passive_capable
else "Disabled in engine."
)
return _skip(PreviewToolStatus.SKIPPED_DISABLED, reason)
if resolved.target_type not in spec.applies_to:
return _skip(
PreviewToolStatus.SKIPPED_NOT_APPLICABLE,
f"Not applicable to {resolved.target_type} targets.",
)
if spec.consumes and not (spec.consumes & available):
kinds = " or ".join(ASSET_KIND_LABELS[k] for k in sorted(spec.consumes))
reason = f"No earlier stage produces {kinds}."
warnings.append(f"{spec.title}: {reason}")
return _skip(PreviewToolStatus.SKIPPED_NO_INPUT, reason)
missing = [k for k in spec.api_keys if k not in configured]
if missing and len(missing) == len(spec.api_keys) and spec.requires_api_keys:
reason = f"{spec.title} skipped. API key not configured."
warnings.append(reason)
return _skip(PreviewToolStatus.SKIPPED_NEEDS_KEY, reason)
if missing:
warnings.append(
f"{spec.title}: no API key for {', '.join(missing)}. Reduced coverage."
)
transport = (resolved.transports or {}).get(spec.name) or {}
quiet = resolved.intensity == Intensity.PASSIVE.value and spec.passive_capable
return PreviewTool(
capability=spec.name,
label=spec.title,
status=PreviewToolStatus.WILL_RUN,
rate=None if quiet else transport.get("rate"),
threads=transport.get("threads"),
timeout=transport.get("timeout"),
)
class _DraftEngine:
def __init__(self, data) -> None:
self.intensity = data.intensity
self.global_headers = []
self.tool_options = {}
self.stages = _validate_stages(data.stages)
async def preview_engine(
data, session: AsyncSession | None = None
) -> EnginePreviewResult:
if data.target_type not in {t.value for t in TargetType}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown target type '{data.target_type}'.",
)
_validate_intensity(data.intensity)
context: object | None = None
if data.context is not None:
context = data.context.model_dump()
elif data.context_id is not None and session is not None:
context = (
await session.execute(
select(ScanContext).where(ScanContext.id == data.context_id)
)
).scalar_one_or_none()
if context is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scan context not found"
)
resolved = merge_engine_context(
_DraftEngine(data), context, "preview", data.target_type
)
phases, warnings = stage_effects(resolved, set())
return EnginePreviewResult(
phases=phases,
resolved_stages=resolved.stages,
resolved=PreviewResolved(
header_names=list(resolved.headers),
global_rate_limit_ceiling=resolved.global_rate_limit_ceiling,
per_tool_rate_limits=resolved.per_tool_rate_limits,
excluded_subdomains=resolved.excluded_subdomains,
excluded_paths=resolved.excluded_paths,
excluded_ips=resolved.excluded_ips,
included_subdomains=resolved.included_subdomains,
follow_redirects=resolved.follow_redirects,
http_protocol=resolved.http_protocol,
),
warnings=warnings,
)