Files
rengine/shared/models/scan_engine.py
T
Yogesh OjhaandClaude Opus 5 dbb341e5a4 feat(engines): IP enrichment is shown and locked on, not hidden
a5d835df made ip_enrichment catalog_hidden. That removed it from the
builder entirely, which is worse: a stage that always runs and that
every address surface reads should not be invisible. An operator has
to know it exists.

It is now `always_on` — served in the catalog with the flag, rendered
as a row with an "Always on" badge and a checked, disabled switch.
asset_seed keeps catalog_hidden: it is the focused-rescan mechanism and
is meaningless in an authored engine.

Neither flag is presentational. Hiding the control was never the
mechanism, because the resolver still honoured a "no" from two other
directions: a launch could send overrides {ip_enrichment: {enabled:
false}} (verified: preview reported "Disabled in engine"), and an
engine document saved before the stage was hidden kept disabling it.
merge_engine_context now takes a hidden stage's defaults outright and
forces enabled=True on an always_on one. validate_overrides and
_validate_stages strip the key rather than reject, so a legacy document
and an imported YAML both keep saving — rejecting is what broke the
Full Sweep and Passive Recon presets in 4600a4c8. preset_stages skips
both, so even Blank cannot author a "no". asset_seed is unaffected by
the force: ScanService enables it on resolved.stages after the merge.

Also fixes the copy, which is what prompted this. The description read
"Sweep up every address the scan found and complete what only the
finished run can know" — the only one of twenty not written as a plain
statement of what the stage produces. Five more in the same pass:
vhost.enabled used jargon and contradicted its own stage description,
endpoint_probe.skip_static said nothing concrete,
dns_batch_concurrency carried a measurement anecdote in a tooltip, and
three stages inherited the placeholder "Run this stage during a scan."
which is the text YAML autocomplete shows for the toggle.

Verified: catalog 18 -> 19 with always_on true; POST/PATCH/import of a
document disabling it all save with the key stripped; preview reports
will_run with the engine document and an override both saying no; a
real scan launched with that override resolved {"enabled": true} and
finished ip_enrichment success {"ips": 2, "new": 0, "enriched": 2},
with the stored overrides recording only the two that applied; a
focused rescan still seeds (asset_seed success). Row checked in light
and dark; the YAML lint warns only on an explicit enabled: false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N2QTNoW3jEi3pX7uMLXBkk
2026-09-08 08:48:38 +05:30

188 lines
6.0 KiB
Python

import uuid
from datetime import datetime
from functools import partial
from pydantic import BaseModel, field_validator
from pydantic import Field as PydanticField
from sqlalchemy import Column
from sqlalchemy.types import JSON, Text
from sqlmodel import Field, SQLModel
from shared.definitions.constants import DEFAULT_GLOBAL_THREADS
from shared.models.scan_context import ScanContextCreate
from shared.utils.datetime import utc_now
from shared.utils.validation import clean_name, clean_optional_name
class ScanEngine(SQLModel, table=True):
__tablename__ = "scan_engines"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True)
project_id: uuid.UUID = Field(foreign_key="projects.id", index=True)
created_by: uuid.UUID = Field(foreign_key="users.id")
name: str = Field(max_length=200)
description: str | None = Field(default=None, max_length=1000)
intensity: str = Field(default="normal")
global_threads: int = Field(default=DEFAULT_GLOBAL_THREADS)
global_http_crawl: bool = Field(default=True)
global_headers: list = Field(
default_factory=list, sa_column=Column(JSON, nullable=False)
)
stages: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False))
yaml_source: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
tool_options: dict = Field(
default_factory=dict, sa_column=Column(JSON, nullable=False)
)
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
last_used_at: datetime | None = Field(default=None)
class ScanEngineCreate(BaseModel):
name: str
description: str | None = None
intensity: str = "normal"
global_threads: int = DEFAULT_GLOBAL_THREADS
global_http_crawl: bool = True
global_headers: list[str] = PydanticField(default_factory=list)
stages: dict[str, dict] = PydanticField(default_factory=dict)
yaml_source: str | None = None
tool_options: dict[str, str] = PydanticField(default_factory=dict)
_validate_name = field_validator("name")(partial(clean_name, max_len=200))
class ScanEngineUpdate(BaseModel):
name: str | None = None
description: str | None = None
intensity: str | None = None
global_threads: int | None = None
global_http_crawl: bool | None = None
global_headers: list[str] | None = None
stages: dict[str, dict] | None = None
yaml_source: str | None = None
tool_options: dict[str, str] | None = None
_validate_name = field_validator("name")(partial(clean_optional_name, max_len=200))
class EngineUsage(BaseModel):
schedules: int = 0
scans: int = 0
class ScanEngineRead(BaseModel):
id: uuid.UUID
project_id: uuid.UUID
created_by: uuid.UUID
name: str
description: str | None
intensity: str
global_threads: int
global_http_crawl: bool
global_headers: list[str]
stages: dict[str, dict]
yaml_source: str | None
tool_options: dict[str, str] = PydanticField(default_factory=dict)
usage: EngineUsage = PydanticField(default_factory=EngineUsage)
created_at: datetime
updated_at: datetime
last_used_at: datetime | None
class StageField(BaseModel):
name: str
title: str
description: str | None = None
type: str
default: object = None
options: list[str] | None = None
option_labels: dict[str, str] | None = None
minimum: int | None = None
maximum: int | None = None
scale: str | None = None
widget: str | None = None
# what a widget selects, when the widget needs it (wordlist kind)
kind: str | None = None
launch: bool = False
class StageCatalogEntry(BaseModel):
name: str
title: str
description: str
phase: str
level: int
applies_to: list[str]
tools: list[str]
api_keys: list[str]
requires_api_keys: bool = False
touches_target: bool = True
always_on: bool = False
launch_fields: list[str] = PydanticField(default_factory=list)
group: str
role: str
consumes: list[str] = PydanticField(default_factory=list)
produces: list[str] = PydanticField(default_factory=list)
defaults: dict
fields: list[StageField] = PydanticField(default_factory=list)
class StageGroupEntry(BaseModel):
key: str
label: str
class ToolOption(BaseModel):
name: str
label: str
phase: str
example: str
class EnginePreset(BaseModel):
name: str
title: str
description: str
stages: dict[str, dict]
class EngineCatalog(BaseModel):
phases: list[str]
stages: list[StageCatalogEntry]
rate_tools: list[str]
tool_options: list[ToolOption]
presets: list[EnginePreset]
target_types: list[str]
groups: list[StageGroupEntry] = PydanticField(default_factory=list)
seed_produces: dict[str, list[str]] = PydanticField(default_factory=dict)
class PreviewResolved(BaseModel):
header_names: list[str] = PydanticField(default_factory=list)
global_threads: int = DEFAULT_GLOBAL_THREADS
global_rate_limit_ceiling: int | None = None
per_tool_rate_limits: dict[str, int] = PydanticField(default_factory=dict)
excluded_subdomains: list[str] = PydanticField(default_factory=list)
excluded_paths: list[str] = PydanticField(default_factory=list)
excluded_ips: list[str] = PydanticField(default_factory=list)
included_subdomains: list[str] = PydanticField(default_factory=list)
follow_redirects: bool | None = None
http_protocol: str = "both"
class EnginePreviewResult(BaseModel):
phases: list = PydanticField(default_factory=list)
resolved_stages: dict[str, dict] = PydanticField(default_factory=dict)
resolved: PreviewResolved = PydanticField(default_factory=PreviewResolved)
warnings: list[str] = PydanticField(default_factory=list)
class EnginePreviewRequest(BaseModel):
target_type: str
context_id: uuid.UUID | None = None
context: ScanContextCreate | None = None
intensity: str = "normal"
global_threads: int = DEFAULT_GLOBAL_THREADS
stages: dict[str, dict] = PydanticField(default_factory=dict)