Files
rengine/shared/models/scan_engine.py
T
Yogesh OjhaandClaude Opus 5 b0a47ba087 feat(oast): an instance-owned callback server, and a tier that pays the wait once
Out-of-band testing is an instance setting — `off`, `self_hosted` or
`public` — with the server, the token (the INTERACTSH API key row) and the
wait resolved once in `shared/services/oast.py` and read on the stage
thread. A mode that cannot be used is off and says why: self-hosted with no
server, or public without the disclosure acknowledged. A fresh instance is
off. `POST /oast/test` asks a public address for `/poll` over HTTPS and
reports that the server answered, never that OAST works.

The engine keeps one switch. `FieldNeeds` puts the resource key on the
field and `GET /engines/catalog` fills `unavailable_reason`, so the builder
renders the instance reason beside a switch that stays settable.

Measured on nuclei v3.11.1: with `-no-interactsh` a check that needs a
callback still loads and the request is refused — 0 requests, exit 0. So
the batch reported COMPLETED and every origin in it read `scanned` for
checks that never touched the target. The selection now drops them when
nothing is listening, one coverage row reports the count and the reason,
and `Tier.OAST` is left out of the planned tiers.

Membership is what a check references, not what it is tagged: 615 of the
12,586 official rows reference an interactsh placeholder and 233 of those
carry no `oast` tag. `vuln_templates.needs_oast` is parsed at index time; a
library resync fills the column.

The wait is paid on out-of-band batches alone, which sweep for 30 minutes
so it is paid fewer times, and the tier names the server on its own rows.
A finding's callback is rendered on the sheet from one label map; the list
path is bared to the metadata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:53:01 +05:30

190 lines
5.8 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.enums.scan import Intensity
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)
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=Intensity.NORMAL.value)
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)
)
builtin: bool = Field(default=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 = Intensity.NORMAL.value
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_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_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)
builtin: bool = False
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
tier: str = "basic"
widget: str | None = None
kind: str | None = None
launch: bool = False
needs: str | None = None
unavailable_reason: str | None = None
class StageTransport(BaseModel):
tool: str
rates: dict[str, int | None]
threads: dict[str, int]
timeout: int
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
passive_capable: bool = False
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)
transport: StageTransport | None = None
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_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 = Intensity.NORMAL.value
stages: dict[str, dict] = PydanticField(default_factory=dict)