mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 23:31:31 +02:00
A scan no longer needs a saved engine. The launch dialog has two configuration modes: Scan Engine (default, runs a saved engine exactly as configured, shown as a pipeline and its YAML) and Custom Scan (pick the capabilities to run; dependent and supporting stages are included automatically). Targets are created on launch from typed values, the scan context is a first-class field, and "Run again" restores the engine or the selection that ran. Backend - scans.engine_id is nullable; an absent engine resolves through AdHocEngine and the run is named after its capabilities (plan_label) - ScanCreate/ScanBatchCreate accept target_value(s) (create or reuse via TargetService.ensure_targets), a per-run intensity, and preview works for a target that does not exist yet - every stage declares consumes/produces (AssetKind), group (StageGroup) and role (capability | support); the registry validates them and the catalog serves them with groups and seed_produces - stage_effects walks levels so a producer only feeds later levels and marks an unfed consumer skipped_no_input Frontend - components/scans/launch/: launch-dialog, launch-state, target-picker, plan-picker (segmented Scan Engine | Custom Scan, pipeline + YAML Config views, capability pills with full per-stage settings), stage-pill, stage-config-popover, launch-summary + what-runs, launch-context-field, launch-context-form, save-engine-dialog - utilities/launch-plan.ts resolves implied producers and following support stages; the last engine and last custom selection persist - quick-scan-fields offers presets and engines in one select; the command palette offers "Scan <value>"; scan list action is "Run again" - effect-panel drops its explanatory header; NO_CONTEXT_LABEL is "No scan context"; launch-modal and execution-preview are removed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014erb6Jbm6v9sxPgYFBxB7E
161 lines
5.9 KiB
Python
161 lines
5.9 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_global_threads,
|
|
_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.config import Scale
|
|
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 so a producer only feeds later 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. This stage sends traffic to the target."
|
|
if resolved.intensity == Intensity.PASSIVE.value and spec.touches_target
|
|
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 the {kinds} this stage reads."
|
|
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."
|
|
)
|
|
|
|
scaled = spec.config_model.scaled_fields()
|
|
|
|
def _scaled(kind: Scale):
|
|
return next((values[f] for f, (s, _) in scaled.items() if s is kind), None)
|
|
|
|
return PreviewTool(
|
|
capability=spec.name,
|
|
label=spec.title,
|
|
status=PreviewToolStatus.WILL_RUN,
|
|
rate=_scaled(Scale.RATE),
|
|
threads=_scaled(Scale.THREADS),
|
|
timeout=_scaled(Scale.TIMEOUT),
|
|
)
|
|
|
|
|
|
class _DraftEngine:
|
|
def __init__(self, data) -> None:
|
|
self.intensity = data.intensity
|
|
self.global_threads = data.global_threads
|
|
self.global_http_crawl = True
|
|
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)
|
|
_validate_global_threads(data.global_threads)
|
|
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_threads=resolved.global_threads,
|
|
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,
|
|
)
|