mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 07:11:28 +02:00
reNgine 2 answered this with one global keyword list matched by `icontains`, and rebuilt that query once per table row — `get_is_interesting` was a SerializerMethodField, so a 5,000-host page ran the whole unindexable OR-chain 5,000 times. It was also dead until the user saved the settings form once, since `get_interesting_subdomains` returns none() with no custom_type row. The rule survives; everything around it is rebuilt. An interest rule IS a saved HOST_QUERY query, so there is no second matcher to keep in sync, and the keyword box compiles to `host:[…] or title:[…]` behind the same grammar. Signals persist to interest_signals, roll up in SQL onto subdomains.interest_score/band/kinds so sorting and `is:interesting` stay index-backed, and the scan carries a signature of the rules it was labelled against — a stale read refreshes in the background, so editing a keyword still re-labels every past scan without a read-time query. interest/ is a top-level package mounted into api and worker like stages/ and reports/, with providers auto-discovered from disk. Three ship: rules the keyword lists and 19 saved queries correlation what stands out from the estate, which no keyword can see ai a judgement, labelled as one everywhere it appears Correlation is the half a rule cannot express: an ASN carrying under 5% of the surface, software or a favicon on three hosts or fewer, the one asset answering directly while the rest sits behind a CDN. All gated on a minimum estate size, because rarity means nothing without a norm. On gov.cy: 226 flagged of 1,379 in 0.4s, 29 of them critical. AI sees hostname, status, page title and technology for hosts that answered, and nothing else — never a response body — deduped to three samples per identical response shape. It may answer only in a closed schema, a host it was not given is dropped rather than stored, and its sentence must state what was observed, never what is exploitable. Page titles are attacker-controlled, so they travel as JSON data with strip_control and a cap, never interpolated into the instruction, and nothing it returns triggers an action. Every AI signal stores its model and prompt version, and the UI never lets it read as an observation. Then the judgement graduates: suggest.py shows the model what it flagged and asks for the query that would catch it again without a model. Each proposal is compiled, counted against the scan and dropped if it errors, matches nothing or already exists; nothing is stored until a person presses Add, after which it runs on every keyless install with no cost. Alerts are delta-only — hosts the target has never flagged before, and only those in a high band or matched by a rule the user asked to be notified about. Four bugs found on the way. `dr` matched drones.gov.cy, so shipped hostname rules are anchored on label boundaries and false positives went from 6 hosts to 0. predicates.interest_signal aliased InterestSignal while its condition used the unaliased class, uncorrelating the EXISTS so every interest: field returned the unfiltered total. The correlation aggregates needed explicit output aliases or a bare `t` collided with the CTE and printed a UUID inside a sentence. And dismiss removed the signals while leaving the denormalised rank, so the row stayed in the list. The asset query compiler moves to shared/ because the worker has no api/ and has to compile a rule at finalize; api keeps a shim that aliases every submodule into sys.modules, so no existing import changes. The rule library lives at /interest, not in the Arsenal. The Arsenal is tools. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVh1CGtjDA33fupXjLGbk2
270 lines
8.3 KiB
Python
270 lines
8.3 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_SEED_ASSETS, 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 cannot be empty."
|
|
raise ValueError(msg)
|
|
return self
|
|
|
|
|
|
class Scan(SQLModel, table=True):
|
|
__tablename__ = "scans"
|
|
|
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=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)
|
|
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)
|
|
)
|
|
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)
|
|
|
|
|
|
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_SEED_ASSETS
|
|
)
|
|
parent_scan_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
|
|
dimension: str = Field(max_length=32)
|
|
assets: list[str] = Field(min_length=1, max_length=MAX_SEED_ASSETS)
|
|
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 _known_dimension(self):
|
|
if self.dimension not in SURFACE_ORDER:
|
|
msg = f"Unknown dimension '{self.dimension}'."
|
|
raise ValueError(msg)
|
|
cleaned = [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.assets = list(dict.fromkeys(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
|
|
|
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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] = []
|