mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-21 07:41:28 +02:00
Pause revoked with terminate=True and SIGKILL. A SIGKILLed celery child runs no finally and no watchdog thread, so its subprocess.Popen tool was reparented to init and kept hitting the target with its budget and idle timer dead — an unbounded scan against a host the operator believes is paused. Revoke now drops the queued tasks only; a task already running stops through its own abort check, which kills the tool and reaps it. tools/runner/abort.py binds the stage's check for the whole process, so a tool stops even where the call site passed no should_stop. The blocking run() path has no abort poll yet and still ends at the tool's own timeout. Resume was answered by the worker, so the api replied "paused" and the page waited on a celery round trip — with the worker busy the control looked dead. The api now takes the row lock, folds the pause, sets RUNNING and bumps the epoch itself, then dispatches a task that only builds the canvas. Pause and resume answer in ~20ms with the true status and the stores hand the row back, so the page flips without a refetch. run_scan read run_epoch at execution time, so a launch still queued when a scan was paused and resumed adopted the new epoch and dispatched a second canvas beside the resumed one: two subdomain_discovery processes on one scan. It carries its epoch like every other signature now, and superseded() is the one definition all four guards share. Also: cancel kept the open pause out of paused_seconds, so a run cancelled after sitting paused reported the pause as run time; pause and cancel take the row lock, so five concurrent calls give one winner instead of duplicate ledger entries; a watch probe refuses to pause, since its host waits on a terminal status that pause never reaches; the hero, ribbon and empty state get a paused arm rather than falling into the settled one and claiming a delta for a run that has not looked yet; isOpenStatus mirrors SCAN_OPEN_STATUSES so Compare, the timeline and bulk cancel agree with the api; and the pause confirm is gone — it removes nothing, so the cost is stated on the control instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rg2cefwCvz9f77A2SHEHDb
391 lines
12 KiB
Python
391 lines
12 KiB
Python
import uuid
|
|
from datetime import datetime
|
|
|
|
from pydantic import BaseModel, ConfigDict, model_validator
|
|
from sqlalchemy import Column
|
|
from sqlalchemy.types import JSON
|
|
from sqlmodel import Field, SQLModel
|
|
|
|
from shared.definitions.constants import MAX_SCAN_BATCH
|
|
from shared.definitions.rescan import MAX_RUN_ASSETS, MAX_RUN_SCANS, SeedKind
|
|
from shared.definitions.surface import SURFACE_ORDER
|
|
from shared.enums.scan import ScanScope, ScanStatus
|
|
from shared.services.scan_resolve import ResolvedScanConfig
|
|
from shared.utils.datetime import utc_now
|
|
|
|
SCAN_STATUSES = tuple(s.value for s in ScanStatus)
|
|
SCAN_SCOPES = tuple(s.value for s in ScanScope)
|
|
|
|
|
|
class SeedAsset(BaseModel):
|
|
"""One asset a focused scan starts from."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
kind: str = Field(max_length=16)
|
|
value: str = Field(max_length=500)
|
|
|
|
@model_validator(mode="after")
|
|
def _known_kind(self):
|
|
if self.kind not in {k.value for k in SeedKind}:
|
|
msg = f"Unknown seed kind '{self.kind}'."
|
|
raise ValueError(msg)
|
|
if not self.value.strip():
|
|
msg = "Seed asset value is required."
|
|
raise ValueError(msg)
|
|
return self
|
|
|
|
|
|
class SeedPick(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
value: str = Field(max_length=500)
|
|
scan_id: uuid.UUID | None = None
|
|
|
|
|
|
class QuerySelection(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
filter: dict = Field(default_factory=dict)
|
|
scan_ids: list[uuid.UUID] = Field(default_factory=list, max_length=MAX_RUN_SCANS)
|
|
|
|
|
|
class SeedSelection(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
dimension: str = Field(max_length=32)
|
|
picks: list[SeedPick] = Field(default_factory=list, max_length=MAX_RUN_ASSETS)
|
|
query: QuerySelection | None = None
|
|
exclude: list[str] = Field(default_factory=list, max_length=MAX_RUN_ASSETS)
|
|
|
|
@model_validator(mode="after")
|
|
def _one_form(self):
|
|
if self.dimension not in SURFACE_ORDER:
|
|
msg = f"Unknown dimension '{self.dimension}'."
|
|
raise ValueError(msg)
|
|
if bool(self.picks) == (self.query is not None):
|
|
msg = "Provide either picks or a query, not both."
|
|
raise ValueError(msg)
|
|
return self
|
|
|
|
|
|
class SeedGroup(BaseModel):
|
|
"""Seed values for one target's scan."""
|
|
|
|
scan_id: uuid.UUID
|
|
target_id: uuid.UUID
|
|
target_value: str
|
|
values: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class ResolvedSelection(BaseModel):
|
|
groups: list[SeedGroup] = Field(default_factory=list)
|
|
total: int = 0
|
|
capped: bool = False
|
|
matched: int | None = None
|
|
|
|
|
|
class SeedGroupSummary(BaseModel):
|
|
target_id: uuid.UUID
|
|
target_value: str
|
|
scan_id: uuid.UUID
|
|
count: int
|
|
|
|
|
|
class RunPreview(BaseModel):
|
|
dimension: str
|
|
seed_kind: str
|
|
asset_count: int = 0
|
|
matched: int | None = None
|
|
target_count: int = 0
|
|
capped: bool = False
|
|
targets: list[SeedGroupSummary] = Field(default_factory=list)
|
|
stage_titles: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class FocusedRunRead(BaseModel):
|
|
"""The scans one rescan created."""
|
|
|
|
run_group_id: uuid.UUID
|
|
scans: list["ScanRead"] = Field(default_factory=list)
|
|
asset_count: int = 0
|
|
matched: int | None = None
|
|
target_count: int = 0
|
|
capped: bool = False
|
|
stage_titles: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class Scan(SQLModel, table=True):
|
|
__tablename__ = "scans"
|
|
|
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
project_id: uuid.UUID = Field(foreign_key="projects.id", index=True)
|
|
target_id: uuid.UUID = Field(
|
|
foreign_key="targets.id", index=True, ondelete="CASCADE"
|
|
)
|
|
engine_id: uuid.UUID | None = Field(default=None, index=True)
|
|
engine_name: str = Field(max_length=200)
|
|
context_id: uuid.UUID | None = Field(default=None)
|
|
context_name: str | None = Field(default=None, max_length=200)
|
|
schedule_id: uuid.UUID | None = Field(default=None, index=True)
|
|
schedule_type: str | None = Field(default=None, max_length=20)
|
|
scope: str = Field(default=ScanScope.FULL.value, max_length=16, index=True)
|
|
parent_scan_id: uuid.UUID | None = Field(default=None, index=True)
|
|
run_group_id: uuid.UUID | None = Field(default=None, index=True)
|
|
execution_config: dict = Field(sa_column=Column(JSON, nullable=False))
|
|
status: str = Field(default=ScanStatus.PENDING.value, index=True)
|
|
celery_task_ids: list = Field(
|
|
default_factory=list, sa_column=Column(JSON, nullable=False)
|
|
)
|
|
run_epoch: int = Field(default=0)
|
|
paused_at: datetime | None = Field(default=None)
|
|
paused_seconds: float = Field(default=0.0)
|
|
subdomains_found: int = Field(default=0)
|
|
ips_found: int = Field(default=0)
|
|
open_ports_found: int = Field(default=0)
|
|
http_assets_found: int = Field(default=0)
|
|
vulnerabilities_found: int = Field(default=0)
|
|
endpoints_found: int = Field(default=0)
|
|
error: str | None = Field(default=None, max_length=2000)
|
|
interest_signature: str | None = Field(default=None, max_length=64)
|
|
interest_judged_at: datetime | None = Field(default=None)
|
|
interest_model: str | None = Field(default=None, max_length=80)
|
|
created_by: uuid.UUID = Field(foreign_key="users.id")
|
|
created_at: datetime = Field(default_factory=utc_now)
|
|
started_at: datetime | None = Field(default=None)
|
|
completed_at: datetime | None = Field(default=None)
|
|
|
|
|
|
def fold_pause(scan: Scan, at: datetime) -> None:
|
|
"""Move an open pause into paused_seconds, so it never counts as run time."""
|
|
if scan.paused_at is None:
|
|
return
|
|
scan.paused_seconds = (scan.paused_seconds or 0.0) + (
|
|
at - scan.paused_at
|
|
).total_seconds()
|
|
scan.paused_at = None
|
|
|
|
|
|
class ScanCreate(BaseModel):
|
|
"""A launch names a saved engine, or runs an ad hoc plan carried in `overrides`."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
engine_id: uuid.UUID | None = None
|
|
context_id: uuid.UUID | None = None
|
|
target_id: uuid.UUID | None = None
|
|
target_value: str | None = Field(default=None, max_length=500)
|
|
overrides: dict[str, dict] = Field(default_factory=dict)
|
|
intensity: str | None = None
|
|
seed_assets: list[SeedAsset] = Field(
|
|
default_factory=list, max_length=MAX_RUN_ASSETS
|
|
)
|
|
parent_scan_id: uuid.UUID | None = None
|
|
run_group_id: uuid.UUID | None = None
|
|
dimension: str | None = Field(default=None, max_length=32)
|
|
|
|
@model_validator(mode="after")
|
|
def _require_target(self):
|
|
if (self.target_id is None) == (not self.target_value):
|
|
msg = "Provide either target_id or target_value."
|
|
raise ValueError(msg)
|
|
return self
|
|
|
|
|
|
class RescanCreate(BaseModel):
|
|
"""Re-run chosen stages against assets picked from an earlier run."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
parent_scan_id: uuid.UUID | None = None
|
|
dimension: str = Field(default="", max_length=32)
|
|
assets: list[str] = Field(default_factory=list, max_length=MAX_RUN_ASSETS)
|
|
selection: SeedSelection | None = None
|
|
stages: list[str] = Field(default_factory=list, max_length=20)
|
|
overrides: dict[str, dict] = Field(default_factory=dict)
|
|
context_id: uuid.UUID | None = None
|
|
intensity: str | None = None
|
|
template_ids: list[str] = Field(default_factory=list, max_length=50)
|
|
|
|
@model_validator(mode="after")
|
|
def _one_selection(self):
|
|
"""Legacy parent_scan_id plus assets normalizes to selection."""
|
|
legacy = self.parent_scan_id is not None or bool(self.assets)
|
|
if legacy == (self.selection is not None):
|
|
msg = "Provide either a selection, or a parent scan and its assets."
|
|
raise ValueError(msg)
|
|
if self.selection is not None:
|
|
self.dimension = self.selection.dimension
|
|
return self
|
|
if self.dimension not in SURFACE_ORDER:
|
|
msg = f"Unknown dimension '{self.dimension}'."
|
|
raise ValueError(msg)
|
|
if self.parent_scan_id is None:
|
|
msg = "Provide the scan these assets came from."
|
|
raise ValueError(msg)
|
|
cleaned = list(dict.fromkeys(a.strip() for a in self.assets if a and a.strip()))
|
|
if not cleaned:
|
|
msg = "Select at least one asset to rescan."
|
|
raise ValueError(msg)
|
|
self.selection = SeedSelection(
|
|
dimension=self.dimension,
|
|
picks=[
|
|
SeedPick(value=value, scan_id=self.parent_scan_id) for value in cleaned
|
|
],
|
|
)
|
|
self.assets = cleaned
|
|
return self
|
|
|
|
|
|
class RescanDimension(BaseModel):
|
|
dimension: str
|
|
label: str
|
|
noun: str
|
|
noun_plural: str
|
|
seed_kind: str
|
|
default_stages: list[str]
|
|
|
|
|
|
class RescanSchema(BaseModel):
|
|
dimensions: list[RescanDimension]
|
|
rescannable_stages: list[str]
|
|
max_assets: int
|
|
max_scans: int
|
|
|
|
|
|
class ScanBatchCreate(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
engine_id: uuid.UUID | None = None
|
|
context_id: uuid.UUID | None = None
|
|
target_ids: list[uuid.UUID] = Field(default_factory=list, max_length=MAX_SCAN_BATCH)
|
|
target_values: list[str] = Field(default_factory=list, max_length=MAX_SCAN_BATCH)
|
|
overrides: dict[str, dict] = Field(default_factory=dict)
|
|
intensity: str | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def _require_targets(self):
|
|
total = len(self.target_ids) + len(self.target_values)
|
|
if total == 0:
|
|
msg = "Select at least one target."
|
|
raise ValueError(msg)
|
|
if total > MAX_SCAN_BATCH:
|
|
msg = f"Select at most {MAX_SCAN_BATCH} targets."
|
|
raise ValueError(msg)
|
|
return self
|
|
|
|
|
|
class ScanRead(BaseModel):
|
|
id: uuid.UUID
|
|
project_id: uuid.UUID
|
|
target_id: uuid.UUID
|
|
engine_id: uuid.UUID | None
|
|
engine_name: str
|
|
context_id: uuid.UUID | None
|
|
context_name: str | None
|
|
schedule_id: uuid.UUID | None = None
|
|
schedule_type: str | None = None
|
|
scope: str = ScanScope.FULL.value
|
|
parent_scan_id: uuid.UUID | None = None
|
|
run_group_id: uuid.UUID | None = None
|
|
seed_count: int = 0
|
|
execution_config: ResolvedScanConfig
|
|
auth_summary: str
|
|
status: str
|
|
subdomains_found: int
|
|
ips_found: int
|
|
open_ports_found: int
|
|
http_assets_found: int
|
|
vulnerabilities_found: int
|
|
endpoints_found: int
|
|
error: str | None
|
|
created_by: uuid.UUID
|
|
created_at: datetime
|
|
started_at: datetime | None
|
|
completed_at: datetime | None
|
|
paused_at: datetime | None = None
|
|
paused_seconds: float = 0.0
|
|
duration_seconds: float | None = None
|
|
new_subdomains: int | None = None
|
|
gone_subdomains: int | None = None
|
|
prev_subdomains_found: int | None = None
|
|
is_first_scan: bool | None = None
|
|
|
|
|
|
class ScanStatusCounts(BaseModel):
|
|
pending: int = 0
|
|
running: int = 0
|
|
paused: int = 0
|
|
completed: int = 0
|
|
failed: int = 0
|
|
cancelled: int = 0
|
|
|
|
|
|
class ScanDailyCount(BaseModel):
|
|
date: str
|
|
count: int
|
|
completed: int = 0
|
|
failed: int = 0
|
|
cancelled: int = 0
|
|
running: int = 0
|
|
paused: int = 0
|
|
pending: int = 0
|
|
new_subdomains: int = 0
|
|
|
|
|
|
class ScanFacet(BaseModel):
|
|
name: str
|
|
count: int
|
|
|
|
|
|
class ScanChanges(BaseModel):
|
|
window: str
|
|
new_subdomains: int
|
|
retired_subdomains: int = 0
|
|
targets_changed: int
|
|
scans_run: int
|
|
failed_runs: int
|
|
|
|
|
|
class ScanTargetGroup(BaseModel):
|
|
target_id: uuid.UUID
|
|
target_value: str
|
|
target_type: str
|
|
scan_count: int
|
|
last_scan_at: datetime
|
|
last_status: str
|
|
running: int
|
|
trend: list[int] = []
|
|
|
|
|
|
class ScanExportRow(BaseModel):
|
|
target: str
|
|
status: str
|
|
engine: str
|
|
context: str | None
|
|
schedule_type: str | None = None
|
|
subdomains: int
|
|
ips: int
|
|
open_ports: int
|
|
vulnerabilities: int
|
|
endpoints: int
|
|
duration_seconds: float | None
|
|
started_at: datetime | None
|
|
completed_at: datetime | None
|
|
created_at: datetime
|
|
|
|
|
|
class ScanStats(BaseModel):
|
|
total: int
|
|
running: int
|
|
by_status: ScanStatusCounts
|
|
last_scan_at: datetime | None
|
|
avg_duration_seconds: float | None
|
|
success_rate: float | None
|
|
daily: list[ScanDailyCount]
|
|
engines: list[ScanFacet] = []
|
|
contexts: list[ScanFacet] = []
|
|
|
|
|
|
FocusedRunRead.model_rebuild()
|