mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-21 07:41:28 +02:00
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
336 lines
9.3 KiB
Python
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
|