Files
rengine/shared/models/scan_correlation.py
T
Yogesh OjhaandClaude Opus 5 2aac7a8d31 feat(scans): focused scans at estate scale
A focused scan was bound to one parent scan, so one target. Selection was a
list of row ids, capped at 200 and pruned to the visible page. The project-wide
result views could not start one at all.

A selection is now a value the server resolves: `SeedSelection` carries either
explicit picks or the query that matches them, and resolves into one group per
target's covering scan. One action fans out into a focused scan per target,
tied by `scans.run_group_id`. `parent_scan_id` + `assets` stays as the older
one-scan form, normalized by a validator, so MCP and the row menu are unchanged.

Resolution reuses the query grammar rather than a second matcher: a `seeds()`
per results service over the existing `_scoped(scope, f, columns=...)` hook,
projecting `(value, scan_id)`. Addresses are the exception, since the derived
CTE folds them across targets and exposes `target_ids`; the resolver maps those
through `SurfaceScopeService.scans_by_target`, as it does for a pick that names
no scan.

`MAX_SEED_ASSETS = 200` becomes `MAX_RUN_ASSETS = 5000` with `MAX_RUN_SCANS = 25`.
One cap, not one per scan: a group is per target, and a lower per-scan cap would
need intra-target chunking that `parent_scan_id` and `AssetRecheck` cannot
express. `matched` is null once the probe limit is reached, so nothing reports
the probe as a total.

Frontend:
- `RowSelection` replaces the per-table `SvelteSet` and the effect that pruned
  it to the current page on every page, sort and filter change
- the `projectWide` guards are gone, so /surface/* can act on what it shows
- the selection bar offers the search as the selection, and states the cap
- `POST /scans/rescan/preview` feeds the dialog a per-target breakdown
- the Endpoints tab gains a query-only entry point

Verified against the dev estate: tech:cloudflare reports 3,484 across 5 targets
in the bar, the dialog and the API; title:Census seeded 3 as 2+1 across gov.np
and gov.ng under one run group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01799buekQvDvH41ns8EdhdH
2026-09-11 21:31:11 +05:30

336 lines
9.3 KiB
Python

import uuid
from pydantic import BaseModel, Field
from shared.definitions.asset_query import MAX_QUERY_LENGTH
from shared.models.asset_query import QueryError
from shared.models.http_asset import HttpAssetRead
from shared.models.ip_address import IpAddressRead
from shared.models.port import PortRead
from shared.models.subdomain import Facet, SubdomainRelation
class SurfaceStat(BaseModel):
key: str
label: str
value: int
filter: str = ""
class AttentionItem(BaseModel):
key: str
label: str
count: int
filter: str
tone: str
class Bucket(BaseModel):
key: str
label: str
count: int
klass: str
class Tally(BaseModel):
name: str
count: int
class ClusterStat(BaseModel):
kind: str
reason: str
value: str
count: int
class SubdomainInsights(BaseModel):
surface: list[SurfaceStat] = Field(default_factory=list)
attention: list[AttentionItem] = Field(default_factory=list)
sources: list[Tally] = Field(default_factory=list)
single_source: int = 0
resolution: list[Bucket] = Field(default_factory=list)
status_reframe: list[Bucket] = Field(default_factory=list)
cert_buckets: list[Bucket] = Field(default_factory=list)
top_tech: list[Tally] = Field(default_factory=list)
tech_total: int = 0
top_asn: list[Tally] = Field(default_factory=list)
geography: list[Tally] = Field(default_factory=list)
geo_total: int = 0
clusters: list[ClusterStat] = Field(default_factory=list)
class SubdomainCorrelation(BaseModel):
primary_asset: HttpAssetRead | None = None
services: list[HttpAssetRead] = Field(default_factory=list)
ports: list[PortRead] = Field(default_factory=list)
ip_metas: list[IpAddressRead] = Field(default_factory=list)
related: list[SubdomainRelation] = Field(default_factory=list)
class IpGroupRead(BaseModel):
ip: str
version: int
targets: list[str] = Field(default_factory=list)
asn: int | None = None
asn_org: str | None = None
country: str | None = None
prefix: str | None = None
is_cdn: bool = False
cdn_name: str | None = None
is_alive: bool | None = None
ptr_hostnames: list[str] = Field(default_factory=list)
ports: list[PortRead] = Field(default_factory=list)
host_count: int = 0
hosts: list[str] = Field(default_factory=list)
port_count: int = 0
has_sensitive: bool = False
asset_count: int = 0
class IpGroupPage(BaseModel):
items: list[IpGroupRead] = Field(default_factory=list)
total: int = 0
total_capped: bool = False
error: QueryError | None = None
class IpGroupFilter(BaseModel):
q: str | None = Field(default=None, max_length=MAX_QUERY_LENGTH)
exposure: list[str] = Field(default_factory=list, max_length=4)
asns: list[int] = Field(default_factory=list, max_length=100)
countries: list[str] = Field(default_factory=list, max_length=100)
ports: list[int] = Field(default_factory=list, max_length=200)
services: list[str] = Field(default_factory=list, max_length=200)
cdn: str = Field(default="any", max_length=10)
alive: str = Field(default="any", max_length=10)
version: int = Field(default=0, ge=0, le=6)
sensitive: bool = False
hosted: bool = False
open: bool = False
sort: str = Field(default="hosts", max_length=20)
order: str = Field(default="desc", max_length=4)
limit: int = Field(default=50, ge=1, le=200)
offset: int = Field(default=0, ge=0, le=100_000_000)
def has_facets(self) -> bool:
return bool(
self.exposure
or self.asns
or self.countries
or self.ports
or self.services
or self.cdn != "any"
or self.alive != "any"
or self.version
or self.sensitive
or self.hosted
or self.open
)
class IpFacets(BaseModel):
exposure: list[Facet] = Field(default_factory=list)
asn: list[Facet] = Field(default_factory=list)
country: list[Facet] = Field(default_factory=list)
port: list[Facet] = Field(default_factory=list)
service: list[Facet] = Field(default_factory=list)
class ServiceRead(BaseModel):
id: uuid.UUID
scan_id: uuid.UUID | None = None
target_id: uuid.UUID | None = None
target_value: str | None = None
ip: str
port: int
protocol: str
state: str
service_name: str | None = None
service_class: str
description: str = ""
registered: bool = False
source: str
is_http: bool = False
tls: bool = False
product: str | None = None
version: str | None = None
banner: str | None = None
asn: int | None = None
asn_org: str | None = None
country: str | None = None
prefix: str | None = None
is_cdn: bool = False
cdn_name: str | None = None
scan_policy: str | None = None
host_count: int = 0
hosts: list[str] = Field(default_factory=list)
web_count: int = 0
status_code: int | None = None
url: str | None = None
title: str | None = None
screenshot_path: str | None = None
is_sensitive: bool = False
is_new: bool = False
class ServicePage(BaseModel):
items: list[ServiceRead] = Field(default_factory=list)
total: int = 0
total_capped: bool = False
error: QueryError | None = None
class ServiceFilter(BaseModel):
q: str | None = Field(default=None, max_length=MAX_QUERY_LENGTH)
classes: list[str] = Field(default_factory=list, max_length=10)
ports: list[int] = Field(default_factory=list, max_length=200)
services: list[str] = Field(default_factory=list, max_length=200)
sources: list[str] = Field(default_factory=list, max_length=10)
asns: list[int] = Field(default_factory=list, max_length=100)
countries: list[str] = Field(default_factory=list, max_length=100)
cdn: str = Field(default="any", max_length=10)
http: str = Field(default="any", max_length=10)
sensitive: bool = False
named: bool = False
new: bool = False
sort: str = Field(default="exposure", max_length=20)
order: str = Field(default="desc", max_length=4)
limit: int = Field(default=50, ge=1, le=200)
offset: int = Field(default=0, ge=0, le=100_000_000)
def has_facets(self) -> bool:
return bool(
self.classes
or self.ports
or self.services
or self.sources
or self.asns
or self.countries
or self.cdn != "any"
or self.http != "any"
or self.sensitive
or self.named
or self.new
)
class ServiceFacets(BaseModel):
klass: list[Facet] = Field(default_factory=list, serialization_alias="class")
port: list[Facet] = Field(default_factory=list)
service: list[Facet] = Field(default_factory=list)
source: list[Facet] = Field(default_factory=list)
asn: list[Facet] = Field(default_factory=list)
country: list[Facet] = Field(default_factory=list)
class ExposureBand(BaseModel):
key: str
label: str
count: int
addresses: int
query: str
class ExposureLine(BaseModel):
key: str
label: str
detail: str | None = None
count: int
query: str
class ScanExposure(BaseModel):
services: int = 0
addresses: int = 0
web_services: int = 0
non_web_services: int = 0
answering_http: int = 0
sensitive: int = 0
named: int = 0
passive_only: int = 0
nonstandard_web: int = 0
bands: list[ExposureBand] = Field(default_factory=list)
top_services: list[ExposureLine] = Field(default_factory=list)
coverage: list[ExposureLine] = Field(default_factory=list)
scanned: int = 0
class OriginEvidence(BaseModel):
kind: str
label: str
value: str
class OriginSample(BaseModel):
host: str
url: str
ip: str | None = None
port: int = 0
status_code: int | None = None
title: str | None = None
webserver: str | None = None
cdn_name: str | None = None
asn_org: str | None = None
screenshot_path: str | None = None
class OriginFinding(BaseModel):
kind: str
confidence: str
exposed: OriginSample
fronted: list[OriginSample] = Field(default_factory=list)
fronted_total: int = 0
evidence: list[OriginEvidence] = Field(default_factory=list)
open_ports: list[int] = Field(default_factory=list)
sensitive_ports: list[int] = Field(default_factory=list)
query: str = ""
class OriginExposure(BaseModel):
findings: list[OriginFinding] = Field(default_factory=list)
probed_addresses: int = 0
fronted_assets: int = 0
class CorrelationHost(BaseModel):
id: uuid.UUID
name: str
live: bool = False
status: int | None = None
title: str | None = None
hubs: int = 0
class CorrelationHub(BaseModel):
"""One shared identity and the hosts that carry it."""
id: str
kind: str
value: str
label: str
count: int
share: float = 0.0
common: bool = False
query: str
members: list[int] = Field(default_factory=list)
class CorrelationKindStat(BaseModel):
key: str
label: str
help: str
default: bool = True
hubs: int = 0
hosts: int = 0
common: int = 0
class CorrelationGraph(BaseModel):
hosts: list[CorrelationHost] = Field(default_factory=list)
hubs: list[CorrelationHub] = Field(default_factory=list)
kinds: list[CorrelationKindStat] = Field(default_factory=list)
total_hosts: int = 0
shared_hosts: int = 0
truncated: bool = False