Files
rengine/api/app/services/endpoint.py
T
Yogesh OjhaandClaude Opus 5 0596a7b233 perf(asset-query): the lead set is computed once per burst, not once per tab switch
Every result table asks for its lead counts on mount, on every facet change and on
every tab switch, and the answer is the same each time. After the earlier fixes the
host set costs 832 ms, endpoints 1,609 ms and the project-wide host set 1,653 ms.
Served from redis it is 4-6 ms, on all five dimensions.

Only a settled scope is cached. A live scan's counts change under the reader and a
count has to equal the rows its link opens, so a scope holding any scan that has not
reached a terminal status is always computed fresh.

The entry lives 60 seconds, and the reason is worth stating rather than assuming a
finished scan is immutable. Its rows are — but the counts drawn from them are not: a
reviewer suppressing a finding moves every vulnerability lead, the nightly EPSS and
KEV load moves is:kev, and an interest pass moves is:exposed. Plumbing an
invalidation into each of those and hoping none is ever missed trades a silent wrong
number for a slow right one; a short life serves the burst that this fix is about —
tab switches, facet changes, a second table on the same page — and bounds any count
at a minute stale.

The key is the dimension, the sorted scan ids and the facets, and facets_of drops
what cannot change a count: paging, sorting, and the search box, which the lead set
deliberately ignores. A lead set that did not compute is never stored, so a timeout
is not served for the next minute.

The statement is chunked at 16 branches. A fifty-branch union plans as one enormous
tree, and one that outruns the timeout takes every count in it down.

Verified against the live database: 33 host, 14 address and 18 service leads all
still equal the row count their query opens, served from cache; and two different
facet sets give two different answers rather than sharing an entry. Seven tests
cover the rules — computed once for a finished scan, never cached for a running one,
cached for a failed or cancelled one, distinct per filter, never cached when it did
not compute, and facets_of ignoring paging while keeping facets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013dKUV96WkQQETZBMoAG98C
2026-09-10 23:03:10 +05:30

1444 lines
51 KiB
Python

from __future__ import annotations
import json
from datetime import datetime
from uuid import UUID
from sqlalchemy import (
Integer,
Text,
and_,
case,
cast,
desc,
exists,
func,
join,
literal,
or_,
select,
text,
true,
union_all,
)
from sqlalchemy.dialects.postgresql import JSONB, array
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.services.asset_query import (
NO_JIT,
STATEMENT_TIMEOUT,
EndpointQueryContext,
QueryScope,
QuerySyntaxError,
ScopeLike,
build_endpoint_groups,
build_leads,
compile_endpoint_query,
endpoint_is_new,
endpoint_status_class,
parse_query,
query_error_for,
vuln_suppressed,
)
from app.services.endpoint_tree import (
anomaly_for,
archive_only_for,
build_tree,
static_clause,
)
from app.services.target_names import target_names
from shared.definitions.asset_query import COUNT_CAP, ENDPOINT_QUERY
from shared.definitions.endpoints import (
ADMIN_INTERESTS,
ARCHIVE_SOURCES,
CLASS_LABELS,
COVERAGE_SOURCE_LABELS,
INTEREST_LABELS,
MAX_HOST_CHIPS,
MAX_HOST_PARAMS,
MAX_TREE_ROWS,
PARAM_INTEREST_ORDER,
ROOT_NOISE_DIRS,
ROOT_NOISE_FILES,
SENSITIVE_INTERESTS,
SOURCE_HELP,
SOURCE_KIND,
SOURCE_LABELS,
STATIC_CLASSES,
EndpointClass,
PathInterest,
folder_glyph,
param_interest,
)
from shared.logging import get_logger
from shared.models.asset_query import QueryError, QueryGroups, QueryLeads
from shared.models.endpoint import (
CoverageRead,
Endpoint,
EndpointCoverage,
EndpointDetail,
EndpointFacet,
EndpointFacets,
EndpointFilter,
EndpointPage,
EndpointRead,
EndpointSummary,
EndpointTree,
FolderChip,
GonePage,
HostBrief,
HostIdentity,
HostPage,
MergedLeaf,
MergedLeafPage,
ParamStat,
SourceEvidence,
TreeNode,
VerifyBranchRequest,
VerifyBranchResponse,
)
from shared.models.http_asset import HttpAsset
from shared.models.scan import Scan
from shared.models.vulnerability import Vulnerability
from shared.services.asset_query import lead_cache
from shared.services.celery_dispatch import dispatch_endpoint_verify
from shared.utils.datetime import utc_now
logger = get_logger(__name__)
_FACET_LIMIT = 30
_MERGED_HOST_SAMPLE = 12
_HOST_PAGE_MAX = 200
_HTTP_OK = 200
_W_SENSITIVE = 4.0
_W_CONTROL = 2.0
_W_AUTH = 1.5
_W_API = 1.5
_W_SIZE = 0.5
_AUTH_WALL = (401, 403)
_STATUS_CLASSES = ("2xx", "3xx", "4xx", "5xx", "none")
_STATUS_LABELS = {"none": "Not checked"}
def _label(rows: list[tuple[str, int]], labels: dict) -> list[EndpointFacet]:
return [
EndpointFacet(value=value, label=labels.get(value) or value, count=count)
for value, count in rows
]
def _is_static():
return static_clause()
def _root_noise():
"""The rows every parked hostname has: its root, robots, favicon and .well-known."""
return or_(
and_(
Endpoint.dir_path == "/",
func.coalesce(Endpoint.filename, "").in_(tuple(sorted(ROOT_NOISE_FILES))),
),
*[Endpoint.dir_path.like(f"{d}%") for d in ROOT_NOISE_DIRS],
)
def _archive_only_sources():
return cast(Endpoint.sources, JSONB).contained_by(
cast(literal(json.dumps(sorted(ARCHIVE_SOURCES))), JSONB)
)
def _narrowed(f: EndpointFilter) -> bool:
"""Whether anything but the static switch constrains the rows."""
return bool(
f.q
or f.dir_path
or f.endpoint_class
or f.source
or f.interest
or f.status_class
or f.probed is not None
or f.new
)
def _gone_from(previous_scan_id: UUID, scan_id: UUID):
"""Rows of the previous scan whose signature this scan never recorded."""
current = aliased(Endpoint)
return (
Endpoint.scan_id == previous_scan_id,
~exists(
select(1).where(
current.scan_id == scan_id, current.signature == Endpoint.signature
)
),
)
def _status_bucket(status: int | None) -> str:
if status is None:
return "none"
for name in _STATUS_CLASSES[:4]:
if int(name[0]) * 100 <= status < (int(name[0]) + 1) * 100:
return name
return "none"
_ARRAY_FACETS = {
"source": Endpoint.sources,
"interest": Endpoint.interest,
"param": Endpoint.params,
}
def _needs_quote(value: str) -> bool:
return any(c in value for c in ' ()"[]:=><~') or not value
def _token(field: str, op: str, value: str) -> str:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
quoted = f'"{escaped}"' if _needs_quote(value) else value
return f"{field}{op}{quoted}"
class _Reach:
"""The rows a query counts. With nothing narrowing them, filter the table directly:
joining against a scan-sized id subquery measured 3x slower per aggregate."""
def __init__(self, scope: QueryScope, base, narrowed: bool):
self.scope = scope
ids = base.subquery() if narrowed else None
self.source = (
Endpoint if ids is None else join(Endpoint, ids, Endpoint.id == ids.c.id)
)
self.limit = scope.match(Endpoint.scan_id) if ids is None else true()
def within(self, query):
return query.select_from(self.source).where(self.limit)
def _column_branch(reach: _Reach, column):
return (
reach.within(select(cast(column, Text).label("value"), func.count().label("n")))
.where(column.isnot(None), cast(column, Text) != "")
.group_by(column)
.order_by(desc("n"), cast(column, Text))
.limit(_FACET_LIMIT)
.subquery()
)
def _array_branch(reach: _Reach, column):
value = func.jsonb_array_elements_text(cast(column, JSONB)).column_valued("v")
return (
reach.within(
select(
value.label("value"),
func.count(func.distinct(Endpoint.id)).label("n"),
)
)
.group_by(value)
.order_by(desc("n"), value)
.limit(_FACET_LIMIT)
.subquery()
)
def _status_branch(reach: _Reach):
bucket = case(
*[(endpoint_status_class(name), literal(name)) for name in _STATUS_CLASSES],
else_=None,
)
return (
reach.within(select(bucket.label("value"), func.count().label("n")))
.where(bucket.isnot(None))
.group_by(bucket)
.subquery()
)
class EndpointService:
def __init__(self, session: AsyncSession):
self.session = session
@staticmethod
def _context(scope: QueryScope, now: datetime) -> EndpointQueryContext:
return EndpointQueryContext(scope=scope, now=now)
@staticmethod
def _apply_filter(query, f: EndpointFilter, scope: QueryScope):
if f.host:
query = query.where(Endpoint.host == f.host)
if f.dir_path:
prefix = f.dir_path if f.dir_path.endswith("/") else f"{f.dir_path}/"
if f.subtree:
escaped = (
prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
)
query = query.where(Endpoint.dir_path.like(f"{escaped}%", escape="\\"))
else:
query = query.where(Endpoint.dir_path == prefix)
if f.endpoint_class:
query = query.where(Endpoint.endpoint_class == f.endpoint_class)
if f.source:
query = query.where(
func.jsonb_exists(cast(Endpoint.sources, JSONB), f.source)
)
if f.interest:
query = query.where(
func.jsonb_exists(cast(Endpoint.interest, JSONB), f.interest)
)
if f.status_class:
query = query.where(endpoint_status_class(f.status_class))
if f.probed is not None:
query = query.where(Endpoint.is_probed.is_(f.probed))
if f.new:
query = query.where(endpoint_is_new(scope))
if f.hide_static:
query = query.where(~_is_static())
return query
@staticmethod
def _order(query, f: EndpointFilter):
if f.sort == "relevance":
# lead with what is worth opening: flagged, then input surface, then answering
return query.order_by(
(func.jsonb_array_length(cast(Endpoint.interest, JSONB)) > 0)
.desc()
.nulls_last(),
(Endpoint.param_count > 0).desc(),
Endpoint.is_probed.desc(),
endpoint_status_class("2xx").desc(),
Endpoint.depth.asc(),
Endpoint.host.asc(),
Endpoint.path.asc(),
)
column = {
"path": Endpoint.path,
"url": Endpoint.url,
"host": Endpoint.host,
"status": Endpoint.status_code,
"length": Endpoint.content_length,
"params": Endpoint.param_count,
"depth": Endpoint.depth,
"seen": Endpoint.discovered_at,
"class": Endpoint.endpoint_class,
}.get(f.sort, Endpoint.path)
primary = column.desc() if f.direction == "desc" else column.asc()
return query.order_by(
primary.nulls_last(), Endpoint.host.asc(), Endpoint.path.asc()
)
def _scoped(self, scope: QueryScope, f: EndpointFilter, columns=None):
base = select(Endpoint) if columns is None else select(*columns)
base = base.where(scope.match(Endpoint.scan_id))
return self._apply_filter(base, f, scope)
def _compiled(self, scope: QueryScope, f: EndpointFilter, now: datetime):
return compile_endpoint_query(
parse_query(f.q, ENDPOINT_QUERY), self._context(scope, now)
)
async def search(self, scope: ScopeLike, f: EndpointFilter) -> EndpointPage:
scope = QueryScope.of(scope)
now = utc_now()
base = self._scoped(scope, f)
try:
predicate = self._compiled(scope, f, now)
except QuerySyntaxError as exc:
return EndpointPage(
error=QueryError(
message=exc.message, hint=exc.hint, start=exc.start, end=exc.end
)
)
if predicate is not None:
base = base.where(predicate)
await self.session.execute(text(STATEMENT_TIMEOUT))
await self.session.execute(text(NO_JIT))
size = max(1, min(f.size, 200))
offset = max(0, (max(f.page, 1) - 1) * size)
try:
counted = await self.session.scalar(
select(func.count()).select_from(base.limit(COUNT_CAP + 1).subquery())
)
rows = (
(
await self.session.execute(
self._order(base, f).limit(size).offset(offset)
)
)
.scalars()
.all()
)
except DBAPIError as exc:
await self.session.rollback()
rejected = query_error_for(exc)
if rejected is None:
raise
logger.info("endpoint query rejected", error=str(exc.orig))
return EndpointPage(error=rejected)
total = int(counted or 0)
capped = total > COUNT_CAP
page = EndpointPage(
total=min(total, COUNT_CAP) if capped else total,
total_capped=capped,
page=max(f.page, 1),
size=size,
)
if not rows:
return page
fresh = await self._new_signatures(scope, [r.signature for r in rows])
names = await target_names(self.session, (r.target_id for r in rows))
page.items = [
self._to_read(
row,
is_new=row.signature in fresh,
target_value=names.get(row.target_id),
)
for row in rows
]
return page
def _to_read(
self, row: Endpoint, *, is_new: bool = False, target_value: str | None = None
) -> EndpointRead:
return EndpointRead(
id=row.id,
scan_id=row.scan_id,
target_id=row.target_id,
target_value=target_value,
signature=row.signature,
url=row.url,
host=row.host,
port=row.port,
scheme=row.scheme,
path=row.path,
dir_path=row.dir_path,
filename=row.filename,
extension=row.extension,
depth=row.depth,
params=list(row.params or []),
param_count=row.param_count,
variants=row.variants,
more_variants=row.more_variants,
methods=list(row.methods or []),
sources=list(row.sources or []),
primary_source=row.primary_source,
evidence=_evidence(row),
found_on=row.found_on,
is_probed=row.is_probed,
status_code=row.status_code,
content_type=row.content_type,
content_length=row.content_length,
title=row.title,
words=row.words,
lines=row.lines,
response_time=row.response_time,
redirect_location=row.redirect_location,
tech=list(row.tech or []),
endpoint_class=row.endpoint_class,
interest=list(row.interest or []),
http_asset_id=row.http_asset_id,
subdomain_id=row.subdomain_id,
archive_last_seen=row.archive_last_seen,
discovered_at=row.discovered_at,
is_new=is_new,
)
async def detail(
self, scope: ScopeLike, endpoint_id: UUID
) -> EndpointDetail | None:
scope = QueryScope.of(scope)
row = await self.session.scalar(
select(Endpoint).where(
Endpoint.id == endpoint_id, scope.match(Endpoint.scan_id)
)
)
if row is None:
return None
fresh = await self._new_signatures(scope, [row.signature])
siblings = await self.session.scalar(
select(func.count())
.select_from(Endpoint)
.where(
scope.match(Endpoint.scan_id),
Endpoint.host == row.host,
Endpoint.dir_path == row.dir_path,
Endpoint.id != row.id,
)
)
base = self._to_read(row, is_new=row.signature in fresh)
return EndpointDetail(
**base.model_dump(),
param_samples=list(row.param_samples or []),
discovery=dict(row.discovery or {}),
content_hash=row.content_hash,
siblings=int(siblings or 0),
)
async def _new_signatures(
self, scope: QueryScope, signatures: list[str]
) -> set[str]:
if not signatures:
return set()
rows = await self.session.execute(
select(Endpoint.signature).where(
scope.match(Endpoint.scan_id),
Endpoint.signature.in_(signatures),
endpoint_is_new(scope),
)
)
return set(rows.scalars().all())
async def facets(self, scope: ScopeLike, f: EndpointFilter) -> EndpointFacets:
scope = QueryScope.of(scope)
now = utc_now()
base = select(Endpoint.id).where(scope.match(Endpoint.scan_id))
base = self._apply_filter(base, f, scope)
try:
predicate = self._compiled(scope, f, now)
except QuerySyntaxError:
return EndpointFacets()
if predicate is not None:
base = base.where(predicate)
reach = _Reach(scope, base, f.has_facets() or predicate is not None)
counts = (
await self.session.execute(
reach.within(
select(
func.count().label("total"),
func.count().filter(_is_static()).label("static"),
)
)
)
).one()
out = EndpointFacets()
out.total = int(counts.total or 0)
out.static_total = int(counts.static or 0)
grouped = await self._facets(
[
("endpoint_class", _column_branch(reach, Endpoint.endpoint_class)),
("extension", _column_branch(reach, Endpoint.extension)),
("host", _column_branch(reach, Endpoint.host)),
("status_class", _status_branch(reach)),
]
)
arrays = await self._facets(
[
("source", _array_branch(reach, Endpoint.sources)),
("interest", _array_branch(reach, Endpoint.interest)),
]
)
out.endpoint_class = _label(grouped["endpoint_class"], CLASS_LABELS)
out.extension = _label(grouped["extension"], {})
out.host = _label(grouped["host"], {})
out.status_class = _label(
sorted(grouped["status_class"], key=lambda r: _STATUS_CLASSES.index(r[0])),
_STATUS_LABELS,
)
out.source = _label(arrays["source"], SOURCE_LABELS)
out.interest = _label(arrays["interest"], INTEREST_LABELS)
return out
async def _facets(self, branches) -> dict[str, list[tuple[str, int]]]:
"""One statement for many facets, so each keeps its own index and its own cap."""
rows = await self.session.execute(
union_all(
*[
select(
literal(kind).label("kind"),
branch.c.value.label("value"),
branch.c.n.label("n"),
)
for kind, branch in branches
]
)
)
out: dict[str, list[tuple[str, int]]] = {kind: [] for kind, _ in branches}
for kind, value, n in rows.all():
out[kind].append((str(value), int(n)))
for values in out.values():
values.sort(key=lambda pair: (-pair[1], pair[0]))
return out
async def leads(self, scope: ScopeLike, f: EndpointFilter) -> QueryLeads:
scope = QueryScope.of(scope)
async def _build() -> QueryLeads:
now = utc_now()
base = select(Endpoint.id).where(scope.match(Endpoint.scan_id))
base = self._apply_filter(base, f, scope)
context = self._context(scope, now)
def predicate_for(query: str):
return compile_endpoint_query(
parse_query(query, ENDPOINT_QUERY), context
)
return await build_leads(
self.session,
base,
ENDPOINT_QUERY.examples,
predicate_for,
filtered=f.has_facets(),
)
return await lead_cache.leads(
self.session,
dimension="endpoints",
scans=scope.ids,
facets=lead_cache.facets_of(f),
build=_build,
)
async def groups(
self, scope: ScopeLike, f: EndpointFilter, key: str
) -> QueryGroups:
scope = QueryScope.of(scope)
now = utc_now()
base = select(Endpoint.id).where(scope.match(Endpoint.scan_id))
base = self._apply_filter(base, f, scope)
try:
predicate = self._compiled(scope, f, now)
except QuerySyntaxError:
return QueryGroups(dimension=key)
if predicate is not None:
base = base.where(predicate)
return await build_endpoint_groups(self.session, base, key)
async def tree(
self, scope: ScopeLike, f: EndpointFilter, mode: str
) -> EndpointTree:
scope = QueryScope.of(scope)
now = utc_now()
base = select(Endpoint.id).where(scope.match(Endpoint.scan_id))
base = self._apply_filter(base, f, scope)
try:
predicate = self._compiled(scope, f, now)
except QuerySyntaxError as exc:
return EndpointTree(
mode=mode,
error=QueryError(
message=exc.message, hint=exc.hint, start=exc.start, end=exc.end
),
)
if predicate is not None:
base = base.where(predicate)
await self.session.execute(text(STATEMENT_TIMEOUT))
await self.session.execute(text(NO_JIT))
previous, _at = await self._previous_scan(scope)
return await build_tree(
self.session,
base,
scope=scope,
mode=mode,
previous_scan_id=previous,
hide_static=f.hide_static,
)
async def _previous_scan(
self, scope: QueryScope
) -> tuple[UUID | None, datetime | None]:
"""The latest earlier scan of the same target that recorded endpoints."""
single = scope.single
if single is None:
return None, None
target = select(Scan.target_id).where(Scan.id == single).scalar_subquery()
cutoff = (
select(func.min(Endpoint.discovered_at))
.where(Endpoint.scan_id == single)
.scalar_subquery()
)
row = (
await self.session.execute(
select(Endpoint.scan_id, func.max(Endpoint.discovered_at).label("at"))
.where(
Endpoint.target_id == target,
Endpoint.scan_id != single,
Endpoint.discovered_at < cutoff,
)
.group_by(Endpoint.scan_id)
.order_by(desc("at"))
.limit(1)
)
).first()
return (row.scan_id, row.at) if row else (None, None)
async def _gone_by_host(
self,
scope: QueryScope,
previous_scan_id: UUID | None,
hosts: list[str],
hide_static: bool,
) -> dict[str, int]:
if previous_scan_id is None or not hosts or scope.single is None:
return {}
query = (
select(Endpoint.host, func.count())
.where(
*_gone_from(previous_scan_id, scope.single), Endpoint.host.in_(hosts)
)
.group_by(Endpoint.host)
)
if hide_static:
query = query.where(~static_clause())
rows = await self.session.execute(query)
return {host: int(n) for host, n in rows.all()}
async def hosts(self, scope: ScopeLike, f: EndpointFilter) -> HostPage:
"""The estate as a ranked table: one row per host, rolled up in SQL so ten thousand page cheaply."""
scope = QueryScope.of(scope)
now = utc_now()
base = select(Endpoint.id).where(scope.match(Endpoint.scan_id))
base = self._apply_filter(base, f, scope)
try:
predicate = self._compiled(scope, f, now)
except QuerySyntaxError as exc:
return HostPage(
error=QueryError(
message=exc.message, hint=exc.hint, start=exc.start, end=exc.end
)
)
if predicate is not None:
base = base.where(predicate)
reach = _Reach(scope, base, f.has_facets() or predicate is not None)
agg, substantive = self._host_aggregate(reach, scope)
if f.hide_root_only:
agg = agg.having(substantive > 0)
await self.session.execute(text(STATEMENT_TIMEOUT))
await self.session.execute(text(NO_JIT))
totals = (
await self.session.execute(
select(func.count(func.distinct(Endpoint.host)), func.count())
.select_from(reach.source)
.where(reach.limit)
)
).one()
root_only = 0
if f.hide_root_only:
parked = (
select(Endpoint.host)
.select_from(reach.source)
.where(reach.limit)
.group_by(Endpoint.host)
.having(substantive == 0)
.subquery()
)
root_only = int(
await self.session.scalar(select(func.count()).select_from(parked)) or 0
)
size = max(1, min(f.size, _HOST_PAGE_MAX))
offset = max(0, (max(f.page, 1) - 1) * size)
ordered = agg.order_by(*self._host_order(agg, f)).limit(size).offset(offset)
rows = (await self.session.execute(ordered)).all()
names = [r.host for r in rows]
interests = await self._host_values(reach, names, Endpoint.interest)
sources = await self._host_values(reach, names, Endpoint.sources)
classes = await self._host_classes(reach, names)
chips = await self._host_chips(reach, names)
identity = await self._host_identity(scope, names)
unfiltered = (
await self._host_unfiltered(scope, names, f.hide_static)
if _narrowed(f)
else {}
)
previous, _at = await self._previous_scan(scope)
gone = await self._gone_by_host(scope, previous, names, f.hide_static)
items = []
for r in rows:
n = int(r.n)
verified = int(r.verified)
mix = {
name: int(getattr(r, f"s{name[0]}"))
for name in _STATUS_CLASSES[:4]
if int(getattr(r, f"s{name[0]}"))
}
if n - verified:
mix["none"] = n - verified
flags = set(interests.get(r.host, []))
host_sources = set(sources.get(r.host, []))
host_chips = chips.get(r.host, [])
items.append(
TreeNode(
key=f"{r.host}/",
name=r.host,
path="/",
host=r.host,
kind="host",
depth=0,
direct_count=int(r.direct),
subtree_count=n,
child_count=int(r.folders),
hosts=1,
status_mix=dict(sorted(mix.items())),
class_mix=classes.get(r.host, {}),
sources=sorted(sources.get(r.host, [])),
interest=sorted(flags),
has_params=int(r.params) > 0,
params=int(r.params),
verified=verified,
unprobed=n - verified,
new_count=int(r.fresh),
gone_count=gone.get(r.host, 0),
anomaly=anomaly_for(
int(r.walled), mix.get("2xx", 0), mix.get("5xx", 0), verified
),
archive_only=archive_only_for(host_sources, mix),
glyph=folder_glyph(flags, int(r.api), n),
sample_url=r.sample,
query=_token("host", ":", r.host),
lazy=True,
folders=int(r.folders),
top_folders=[c.name for c in host_chips if c.path != "/"],
chips=host_chips,
api=int(r.api),
walled=int(r.walled),
unfiltered_count=unfiltered.get(r.host, 0),
identity=identity.get(r.host),
)
)
return HostPage(
items=items,
total=max(0, int(totals[0] or 0) - root_only),
total_endpoints=int(totals[1] or 0),
root_only=root_only,
page=max(f.page, 1),
size=size,
)
@staticmethod
def _host_aggregate(reach: _Reach, scope: QueryScope):
"""The per-host rollup and the count that separates an application from a parked name."""
interest = cast(Endpoint.interest, JSONB)
sensitive = func.bool_or(interest.has_any(array(sorted(SENSITIVE_INTERESTS))))
admin = func.bool_or(
interest.has_any(array(sorted(ADMIN_INTERESTS | {PathInterest.AUTH.value})))
)
top_segment = func.split_part(Endpoint.dir_path, "/", 2)
substantive = func.count().filter(~_root_noise())
control = func.bool_or(interest.has_any(array(sorted(ADMIN_INTERESTS))))
auth = func.bool_or(interest.has_any(array([PathInterest.AUTH.value])))
api_count = func.count().filter(
Endpoint.endpoint_class == EndpointClass.API.value
)
input_count = func.count().filter(Endpoint.param_count > 0)
verified_count = func.count().filter(Endpoint.is_probed.is_(True))
# what a tester would open first: an exposed file, then a control surface, then input and reach
score = (
func.coalesce(cast(sensitive, Integer), 0) * _W_SENSITIVE
+ func.coalesce(cast(control, Integer), 0) * _W_CONTROL
+ func.coalesce(cast(auth, Integer), 0) * _W_AUTH
+ cast(api_count > 0, Integer) * _W_API
+ func.log(input_count + 1)
+ func.log(verified_count + 1)
+ func.log(func.count() + 1) * _W_SIZE
)
agg = (
select(
Endpoint.host.label("host"),
func.count().label("n"),
func.count().filter(Endpoint.is_probed.is_(True)).label("verified"),
func.count().filter(Endpoint.param_count > 0).label("params"),
func.count().filter(Endpoint.dir_path == "/").label("direct"),
func.count(func.distinct(top_segment))
.filter(Endpoint.dir_path != "/")
.label("folders"),
*[
func.count()
.filter(endpoint_status_class(name))
.label(f"s{name[0]}")
for name in _STATUS_CLASSES[:4]
],
func.count()
.filter(Endpoint.endpoint_class == EndpointClass.API.value)
.label("api"),
func.count().filter(endpoint_is_new(scope)).label("fresh"),
func.count()
.filter(Endpoint.status_code.in_(_AUTH_WALL))
.label("walled"),
sensitive.label("sensitive"),
admin.label("admin"),
score.label("score"),
func.min(Endpoint.url).label("sample"),
)
.select_from(reach.source)
.where(reach.limit)
.group_by(Endpoint.host)
)
return agg, substantive
@staticmethod
def _host_order(agg, f: EndpointFilter):
cols = agg.selected_columns
if f.sort in ("host", "path", "url"):
return [cols.host.desc() if f.direction == "desc" else cols.host.asc()]
if f.sort == "relevance":
return [cols.score.desc().nulls_last(), cols.n.desc(), cols.host.asc()]
lead = {
"status": cols.verified,
"verified": cols.verified,
"params": cols.params,
"input": cols.params,
"api": cols.api,
"new": cols.fresh,
}.get(f.sort, cols.n)
return [lead.desc(), cols.n.desc(), cols.host.asc()]
async def _host_values(
self, reach: _Reach, hosts: list[str], column
) -> dict[str, list[str]]:
if not hosts:
return {}
value = func.jsonb_array_elements_text(cast(column, JSONB)).column_valued("v")
rows = await self.session.execute(
select(Endpoint.host, value)
.select_from(reach.source)
.where(reach.limit)
.where(Endpoint.host.in_(hosts))
.group_by(Endpoint.host, value)
)
out: dict[str, list[str]] = {}
for host, v in rows.all():
out.setdefault(host, []).append(str(v))
return out
async def _host_classes(
self, reach: _Reach, hosts: list[str]
) -> dict[str, dict[str, int]]:
if not hosts:
return {}
rows = await self.session.execute(
select(Endpoint.host, Endpoint.endpoint_class, func.count())
.select_from(reach.source)
.where(reach.limit)
.where(Endpoint.host.in_(hosts))
.group_by(Endpoint.host, Endpoint.endpoint_class)
)
out: dict[str, dict[str, int]] = {}
for host, klass, n in rows.all():
out.setdefault(host, {})[klass] = int(n)
return out
async def _host_chips(
self, reach: _Reach, hosts: list[str]
) -> dict[str, list[FolderChip]]:
"""Every top-level folder of each host, ranked the way the outline ranks them."""
if not hosts:
return {}
interest = cast(Endpoint.interest, JSONB)
segment = func.split_part(Endpoint.dir_path, "/", 2).label("seg")
rows = await self.session.execute(
select(
Endpoint.host,
segment,
func.count().label("n"),
func.bool_or(
interest.has_any(array(sorted(SENSITIVE_INTERESTS)))
).label("sensitive"),
func.bool_or(interest.has_any(array(sorted(ADMIN_INTERESTS)))).label(
"admin"
),
func.bool_or(interest.has_any(array([PathInterest.AUTH.value]))).label(
"auth"
),
func.count()
.filter(Endpoint.endpoint_class == EndpointClass.API.value)
.label("api"),
func.count()
.filter(or_(endpoint_status_class("2xx"), endpoint_status_class("3xx")))
.label("answering"),
func.bool_and(_archive_only_sources()).label("archived"),
)
.select_from(reach.source)
.where(reach.limit)
.where(
Endpoint.host.in_(hosts),
*[~Endpoint.dir_path.like(f"{d}%") for d in ROOT_NOISE_DIRS],
)
.group_by(Endpoint.host, segment)
)
grouped: dict[str, list[tuple]] = {}
for r in rows.all():
flags: set[str] = set()
if r.sensitive:
flags.add(PathInterest.VCS.value)
if r.admin:
flags.add(PathInterest.ADMIN.value)
if r.auth:
flags.add(PathInterest.AUTH.value)
n = int(r.n)
api = int(r.api)
glyph = folder_glyph(flags, api, n)
answering = int(r.answering)
path = f"/{r.seg}/" if r.seg else "/"
rank = (
1 if path == "/" else 0,
0 if r.sensitive else 1,
0 if (r.admin or r.auth) else 1,
0 if api else 1,
0 if answering else 1,
-n,
path,
)
grouped.setdefault(r.host, []).append(
(
rank,
FolderChip(
name=str(r.seg) if r.seg else "/",
path=path,
count=n,
glyph=glyph,
archive_only=bool(r.archived) and answering == 0,
query=_token("dir", ":" if r.seg else "=", path),
),
)
)
return {
host: [chip for _rank, chip in sorted(items, key=lambda x: x[0])][
:MAX_HOST_CHIPS
]
for host, items in grouped.items()
}
async def _host_identity(
self, scope: QueryScope, hosts: list[str]
) -> dict[str, HostIdentity]:
"""What each host's own HTTP asset says it is, preferring the answer that was a page."""
if not hosts:
return {}
rows = await self.session.execute(
select(
HttpAsset.host,
HttpAsset.status_code,
HttpAsset.title,
HttpAsset.tech,
HttpAsset.id,
)
.where(scope.match(HttpAsset.scan_id), HttpAsset.host.in_(hosts))
.distinct(HttpAsset.host)
.order_by(
HttpAsset.host,
(HttpAsset.status_code == _HTTP_OK).desc().nulls_last(),
HttpAsset.status_code.asc().nulls_last(),
)
)
return {
host: HostIdentity(
status_code=status,
title=title or None,
tech=[str(t) for t in (tech or [])],
http_asset_id=asset_id,
)
for host, status, title, tech, asset_id in rows.all()
}
async def _host_unfiltered(
self, scope: QueryScope, hosts: list[str], hide_static: bool
) -> dict[str, int]:
"""How many endpoints each host holds before the query narrowed it."""
if not hosts:
return {}
query = (
select(Endpoint.host, func.count())
.where(scope.match(Endpoint.scan_id), Endpoint.host.in_(hosts))
.group_by(Endpoint.host)
)
if hide_static:
query = query.where(~_is_static())
return {host: int(n) for host, n in (await self.session.execute(query)).all()}
async def pick(
self, scope: ScopeLike, f: EndpointFilter, limit: int
) -> list[Endpoint]:
"""The rows a filter names, in relevance order, capped: what a proxy is handed."""
scope = QueryScope.of(scope)
now = utc_now()
base = self._scoped(scope, f)
predicate = self._compiled(scope, f, now)
if predicate is not None:
base = base.where(predicate)
base = self._order(base, EndpointFilter(sort="relevance"))
return list((await self.session.execute(base.limit(limit))).scalars().all())
async def host_brief(
self, scope: ScopeLike, host: str, hide_static: bool = True
) -> HostBrief:
"""The sitemap header: what the host is, the facts that pivot, the parameter surface."""
scope = QueryScope.of(scope)
reach = [scope.match(Endpoint.scan_id), Endpoint.host == host]
if hide_static:
reach.append(~_is_static())
async def count(*extra) -> int:
return int(
await self.session.scalar(select(func.count()).where(*reach, *extra))
or 0
)
out = HostBrief(host=host, total=await count())
out.identity = (await self._host_identity(scope, [host])).get(host)
by_class = await self.session.execute(
select(Endpoint.endpoint_class, func.count())
.where(scope.match(Endpoint.scan_id), Endpoint.host == host)
.group_by(Endpoint.endpoint_class)
)
out.by_class = {k: int(v) for k, v in by_class.all()}
out.static_total = int(
await self.session.scalar(
select(func.count()).where(
scope.match(Endpoint.scan_id), Endpoint.host == host, _is_static()
)
)
or 0
)
if not out.total:
return out
out.probed = await count(Endpoint.is_probed.is_(True))
out.live = await count(endpoint_status_class("2xx"))
out.with_params = await count(Endpoint.param_count > 0)
out.api = await count(Endpoint.endpoint_class == EndpointClass.API.value)
out.walled = await count(Endpoint.status_code.in_(_AUTH_WALL))
out.interesting = await count(
func.jsonb_array_length(cast(Endpoint.interest, JSONB)) > 0
)
out.new = await count(endpoint_is_new(scope))
previous, previous_at = await self._previous_scan(scope)
out.previous_scan_at = previous_at
if previous is not None:
gone_scope = [*_gone_from(previous, scope.single), Endpoint.host == host]
if hide_static:
gone_scope.append(~_is_static())
out.gone = int(
await self.session.scalar(select(func.count()).where(*gone_scope)) or 0
)
out.findings = int(
await self.session.scalar(
select(func.count()).where(
scope.match(Vulnerability.scan_id),
Vulnerability.host == host,
~vuln_suppressed(scope),
)
)
or 0
)
name = func.jsonb_array_elements_text(
cast(Endpoint.params, JSONB)
).column_valued("name")
rows = (
await self.session.execute(
select(name, func.count().label("n"))
.select_from(Endpoint)
.where(*reach)
.group_by(name)
)
).all()
stats = [
ParamStat(name=str(n), count=int(c), interest=param_interest(str(n)))
for n, c in rows
]
out.params_total = len(stats)
rank = {k: i for i, k in enumerate(PARAM_INTEREST_ORDER)}
out.params = sorted(
stats,
key=lambda p: (rank.get(p.interest or "", len(rank)), -p.count, p.name),
)[:MAX_HOST_PARAMS]
return out
async def merged_leaves(
self, scope: ScopeLike, f: EndpointFilter
) -> MergedLeafPage:
"""One row per path shape inside a folder, folded across every host that serves it."""
scope = QueryScope.of(scope)
now = utc_now()
base = self._scoped(
scope,
f.model_copy(update={"subtree": False}),
columns=(
Endpoint.id,
Endpoint.host,
Endpoint.path,
Endpoint.filename,
Endpoint.url,
Endpoint.params,
Endpoint.param_count,
Endpoint.endpoint_class,
Endpoint.is_probed,
Endpoint.status_code,
Endpoint.interest,
Endpoint.sources,
endpoint_is_new(scope).label("is_new"),
),
)
try:
predicate = self._compiled(scope, f, now)
except QuerySyntaxError:
return MergedLeafPage()
if predicate is not None:
base = base.where(predicate)
await self.session.execute(text(STATEMENT_TIMEOUT))
await self.session.execute(text(NO_JIT))
rows = (
await self.session.execute(
base.order_by(Endpoint.path, Endpoint.host).limit(MAX_TREE_ROWS + 1)
)
).all()
truncated = len(rows) > MAX_TREE_ROWS
folded: dict[tuple, MergedLeaf] = {}
hosts: dict[tuple, set[str]] = {}
for r in rows[:MAX_TREE_ROWS]:
params = list(r.params or [])
key = (r.path, tuple(params))
leaf = folded.get(key)
if leaf is None:
leaf = MergedLeaf(
key=f"{r.path}?{'&'.join(params)}" if params else r.path,
path=r.path,
name=r.filename or "/",
params=params,
param_count=int(r.param_count or 0),
endpoint_class=r.endpoint_class,
sample_id=r.id,
sample_url=r.url,
sample_status=r.status_code if r.is_probed else None,
query=_token("path", "=", r.path),
)
folded[key] = leaf
hosts[key] = set()
leaf.endpoints += 1
if r.is_new:
leaf.new_count += 1
hosts[key].add(r.host)
bucket = _status_bucket(r.status_code) if r.is_probed else "none"
leaf.status_mix[bucket] = leaf.status_mix.get(bucket, 0) + 1
if not r.is_probed:
leaf.unprobed += 1
leaf.interest = sorted({*leaf.interest, *(r.interest or [])})
leaf.sources = sorted({*leaf.sources, *(r.sources or [])})
items = list(folded.values())
for key, leaf in folded.items():
names = sorted(hosts[key])
leaf.hosts = len(names)
leaf.host_names = names[:_MERGED_HOST_SAMPLE]
items.sort(
key=lambda x: (
0 if x.interest else 1,
0 if x.param_count else 1,
-x.hosts,
x.path,
)
)
return MergedLeafPage(items=items, total=len(items), truncated=truncated)
async def gone(self, scope: ScopeLike, f: EndpointFilter) -> GonePage:
"""Endpoints the previous scan of this target recorded and this scan never did."""
scope = QueryScope.of(scope)
previous, previous_at = await self._previous_scan(scope)
if previous is None or scope.single is None:
return GonePage()
now = utc_now()
base = select(Endpoint).where(*_gone_from(previous, scope.single))
base = self._apply_filter(
base, f.model_copy(update={"new": False}), QueryScope.of(previous)
)
try:
predicate = self._compiled(previous, f, now)
except QuerySyntaxError as exc:
return GonePage(
error=QueryError(
message=exc.message, hint=exc.hint, start=exc.start, end=exc.end
)
)
if predicate is not None:
base = base.where(predicate)
await self.session.execute(text(STATEMENT_TIMEOUT))
await self.session.execute(text(NO_JIT))
size = max(1, min(f.size, 200))
offset = max(0, (max(f.page, 1) - 1) * size)
counted = await self.session.scalar(
select(func.count()).select_from(base.limit(COUNT_CAP + 1).subquery())
)
rows = (
(
await self.session.execute(
self._order(base, f).limit(size).offset(offset)
)
)
.scalars()
.all()
)
total = int(counted or 0)
capped = total > COUNT_CAP
return GonePage(
items=[self._to_read(row) for row in rows],
total=min(total, COUNT_CAP) if capped else total,
total_capped=capped,
page=max(f.page, 1),
size=size,
previous_scan_id=previous,
previous_scan_at=previous_at,
)
async def verify_branch(
self, scan_id: UUID, body: VerifyBranchRequest
) -> VerifyBranchResponse:
"""Queue verification of the unchecked, non-static endpoints under one folder."""
query = select(func.count()).where(
Endpoint.scan_id == scan_id,
Endpoint.host == body.host,
Endpoint.is_probed.is_(False),
Endpoint.endpoint_class.notin_(tuple(STATIC_CLASSES)),
)
if body.dir_path and body.dir_path != "/":
prefix = (
body.dir_path if body.dir_path.endswith("/") else f"{body.dir_path}/"
)
escaped = (
prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
)
query = query.where(Endpoint.dir_path.like(f"{escaped}%", escape="\\"))
unverified = int(await self.session.scalar(query) or 0)
if not unverified:
return VerifyBranchResponse(queued=0, unverified=0, accepted=False)
queued = min(unverified, body.limit)
accepted = dispatch_endpoint_verify(
str(scan_id), body.host, body.dir_path, queued
)
return VerifyBranchResponse(
queued=queued if accepted else 0, unverified=unverified, accepted=accepted
)
async def coverage(self, scope: ScopeLike) -> list[CoverageRead]:
scope = QueryScope.of(scope)
rows = (
(
await self.session.execute(
select(EndpointCoverage)
.where(scope.match(EndpointCoverage.scan_id))
.order_by(EndpointCoverage.started_at)
)
)
.scalars()
.all()
)
return [
CoverageRead(
id=row.id,
source=row.source,
label=COVERAGE_SOURCE_LABELS.get(row.source, row.source),
tool=row.tool,
status=row.status,
hosts_total=row.hosts_total,
hosts_scanned=row.hosts_scanned,
hosts_dropped=list(row.hosts_dropped or []),
urls_found=row.urls_found,
urls_stored=row.urls_stored,
urls_probed=row.urls_probed,
pages_fetched=row.pages_fetched,
depth_reached=row.depth_reached,
errors=row.errors,
capped=row.capped,
cap_reason=row.cap_reason,
error=row.error,
started_at=row.started_at,
ended_at=row.ended_at,
duration_seconds=row.duration_seconds,
)
for row in rows
]
async def summary(
self, scope: ScopeLike, host: str | None = None
) -> EndpointSummary:
scope = QueryScope.of(scope)
reach = [scope.match(Endpoint.scan_id)]
if host:
reach.append(Endpoint.host == host)
row = (
await self.session.execute(
select(
func.count().label("total"),
func.count(func.distinct(Endpoint.host)).label("hosts"),
func.count().filter(endpoint_is_new(scope)).label("fresh"),
func.count().filter(Endpoint.is_probed.is_(True)).label("probed"),
func.count().filter(endpoint_status_class("2xx")).label("live"),
func.count().filter(Endpoint.param_count > 0).label("with_params"),
func.count()
.filter(func.jsonb_array_length(cast(Endpoint.interest, JSONB)) > 0)
.label("interesting"),
).where(*reach)
)
).one()
total = int(row.total)
out = EndpointSummary(total=total, hosts=int(row.hosts))
if not total:
return out
out.new = int(row.fresh or 0)
out.probed = int(row.probed or 0)
out.live = int(row.live or 0)
out.with_params = int(row.with_params or 0)
out.interesting = int(row.interesting or 0)
previous, previous_at = await self._previous_scan(scope)
out.previous_scan_id = previous
out.previous_scan_at = previous_at
if previous is not None:
gone_scope = [*_gone_from(previous, scope.single)]
if host:
gone_scope.append(Endpoint.host == host)
out.gone = int(
await self.session.scalar(select(func.count()).where(*gone_scope)) or 0
)
by_class = await self.session.execute(
select(Endpoint.endpoint_class, func.count())
.where(*reach)
.group_by(Endpoint.endpoint_class)
)
out.by_class = {k: int(v) for k, v in by_class.all()}
source = func.jsonb_array_elements_text(
cast(Endpoint.sources, JSONB)
).column_valued("v")
by_source = await self.session.execute(
select(source, func.count(func.distinct(Endpoint.id)))
.select_from(Endpoint)
.where(*reach)
.group_by(source)
)
out.by_source = {str(k): int(v) for k, v in by_source.all()}
return out
def _evidence(row: Endpoint) -> list[SourceEvidence]:
"""Why each provider believes this endpoint exists, strongest source first."""
discovery = dict(row.discovery or {})
out: list[SourceEvidence] = []
for source in row.sources or []:
entry = discovery.get(source) or {}
observed = entry.get("at")
out.append(
SourceEvidence(
source=source,
label=SOURCE_LABELS.get(source, source),
kind=SOURCE_KIND.get(source, "derived"),
detail=entry.get("detail") or SOURCE_HELP.get(source),
found_on=entry.get("found_on"),
observed_at=_parse(observed),
)
)
return out
def _parse(value) -> datetime | None:
if not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None