Files
rengine/api/app/services/endpoint.py
T
Yogesh OjhaandClaude Opus 5 d41a66deb9 refactor(query): one builder for a query the parser refused
Eleven call sites across six services each spelled the same
QueryError(message=..., hint=..., start=..., end=...) so the UI could
underline the query in place. errors.py already owned the other half
of that vocabulary, query_error_for(DBAPIError); syntax_error() is its
counterpart and the services call it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DafWxfoWZXVDgr9KroJBDZ
2026-09-16 08:45:15 +05:30

1368 lines
48 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,
QueryScope,
QuerySyntaxError,
ScopeLike,
build_endpoint_groups,
build_leads,
compile_endpoint_query,
endpoint_is_new,
endpoint_status_class,
parse_query,
query_error_for,
syntax_error,
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 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.asset_query.tokens import token as _token
from shared.services.celery_dispatch import dispatch_endpoint_verify
from shared.services.surface_query import endpoints as surface_endpoints
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,
}
class _Reach:
"""The rows a query counts."""
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
_context = staticmethod(surface_endpoints.context)
_apply_filter = staticmethod(surface_endpoints.apply_filter)
_order = staticmethod(surface_endpoints.order)
_scoped = staticmethod(surface_endpoints.scoped)
_compiled = staticmethod(surface_endpoints.compiled)
async def seeds(
self, scope: ScopeLike, f: EndpointFilter, limit: int
) -> list[tuple[str, UUID]]:
scope = QueryScope.of(scope)
now = utc_now()
base = self._scoped(scope, f, columns=(Endpoint.host, Endpoint.scan_id))
try:
predicate = self._compiled(scope, f, now)
except QuerySyntaxError:
return []
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.distinct().order_by(Endpoint.host).limit(limit)
)
return [(host, scan_id) for host, scan_id in rows.all()]
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=syntax_error(exc))
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."""
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=syntax_error(exc),
)
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."""
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=syntax_error(exc))
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))
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."""
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=syntax_error(exc))
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,
urls_dropped=dict(row.urls_dropped or {}),
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