Files
rengine/reports/data/source.py
T
Yogesh OjhaandClaude Opus 5 424e6d6e23 feat(reports): a document engine, not an export
reNgine 2 could produce a PDF. This produces a deliverable, and it is built
the way scan engines are built: sections are plug-and-play directories under
reports/, each with a pydantic config model that IS the source of truth. That
model feeds GET /reports/catalog, the builder renders its own form from it, and
the API validates against it. Adding a section is a directory. Scan Engine is to
stages what Report Template is to sections.

WeasyPrint 69 renders it, chosen over Chromium and Typst after measuring what
each can actually do. Only real CSS Paged Media gives a table of contents with
true page numbers, running headers, a PDF outline and footnotes; SysReptor
reaches the same verdict and pipes Chromium's HTML into WeasyPrint for exactly
that reason. reNgine drops the Chromium hop because it templates in Jinja and
draws its charts as SVG in Python, so a browser buys nothing and costs 400 MB
per report. Typst was rejected because a user-uploadable theme would then have
to be Typst code rather than CSS.

A theme is data. shared/definitions/report_theme.py is the schema, six themes
ship as YAML, and a user uploads their own; there is no markup in a theme, so
there is no injection surface. Fonts are vendored so an offline instance still
renders in Latin, Greek and Cyrillic.

Everything the document claims is computed once, in reports/analysis/engine.py:
a posture score whose every deduction is named, weaknesses ranked by the signals
behind the rank rather than severity alone, attack paths that chain observations
into a route, a remediation plan ordered by risk removed, per-dimension change
against the previous run, and caveats that state what was never assessed. Two
narrators read that one brief. Without AI the report still argues its case; with
AI the prose is better and the model never sees a row, only the brief, capped and
cached per check. An AI failure falls back and the report still completes.

Generation is a job. POST /reports queues, the worker walks progress over SSE,
and four formats come out of one render: PDF, self-contained HTML, Markdown and
JSON. The estimate reads each section's own limits, so the page count it promises
is close to the one it delivers.

AI moves out of Settings into its own top-level page: connection, a model per
tier, what it may be used for, what it has cost, and the narrative cache.

Verified on a 1,379-host gov.cy scan: 124 pages in 19.5s across 19 sections,
all six themes, and a broken key still producing a complete report.

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

1011 lines
36 KiB
Python

"""Everything a report can know, gathered once and cached. Counts come from the tables."""
from __future__ import annotations
from datetime import datetime
from functools import cached_property
from typing import Any
from uuid import UUID
from sqlalchemy import and_, case, distinct, func, select, text
from sqlalchemy.orm import Session, aliased
from reports.data.models import (
Address,
Certificate,
DimensionCoverage,
EndpointRow,
Facet,
Finding,
Host,
Service,
StageRun,
)
from shared.definitions.ports import SENSITIVE_PORTS, ServiceClass
from shared.definitions.reports import MAX_REPORT_ROWS, ReportScope
from shared.definitions.surface import SURFACE_KINDS, SURFACE_ORDER, SurfaceDimension
from shared.definitions.vulnerabilities import (
SUPPRESSED_STATES,
Severity,
severity_rank,
)
from shared.enums.scan import ScanStatus
from shared.models.endpoint import Endpoint
from shared.models.http_asset import HttpAsset
from shared.models.ip_address import IpAddress
from shared.models.port import Port
from shared.models.scan import Scan
from shared.models.scan_activity import ScanActivity
from shared.models.subdomain import Subdomain
from shared.models.target import Target
from shared.models.vulnerability import (
Vulnerability,
VulnerabilityCoverage,
VulnerabilityTriage,
)
from shared.utils.datetime import utc_now
_DIM = SurfaceDimension
_MAX_HOSTS_PER_IP = 6
_SERVER_ERROR = 500
_TABLE = {
_DIM.WEB_ASSETS.value: Subdomain,
_DIM.IPS.value: IpAddress,
_DIM.SERVICES.value: Port,
_DIM.ENDPOINTS.value: Endpoint,
_DIM.VULNERABILITIES.value: Vulnerability,
}
_KEY = {
_DIM.WEB_ASSETS.value: (Subdomain.name,),
_DIM.IPS.value: (IpAddress.ip,),
_DIM.SERVICES.value: (Port.ip, Port.number, Port.protocol),
_DIM.ENDPOINTS.value: (Endpoint.signature,),
_DIM.VULNERABILITIES.value: (Vulnerability.fingerprint,),
}
# the column a person reads, which is not always the column that identifies the row
_LABEL = {
_DIM.WEB_ASSETS.value: Subdomain.name,
_DIM.IPS.value: IpAddress.ip,
_DIM.SERVICES.value: Port.ip,
_DIM.ENDPOINTS.value: Endpoint.url,
_DIM.VULNERABILITIES.value: Vulnerability.matched_at,
}
class ReportSource:
"""Reads for one scan, or for a target's most recent covering run per dimension."""
def __init__(
self,
session: Session,
*,
scope: str,
scan: Scan | None,
target: Target,
project_name: str = "",
) -> None:
self.session = session
self.scope = scope
self.scan = scan
self.target = target
self.project_name = project_name
self._memo: dict[str, Any] = {}
# ---------- identity ----------
@property
def subject(self) -> str:
return self.target.target_value
@property
def subject_type(self) -> str:
value = self.target.target_type
return getattr(value, "value", str(value)).lower()
@property
def is_scan_scope(self) -> bool:
return self.scope == ReportScope.SCAN.value
@cached_property
def observed_at(self) -> datetime | None:
if self.scan is not None:
return (
self.scan.completed_at or self.scan.started_at or self.scan.created_at
)
stamps = [c.observed_at for c in self.coverage.values() if c.observed_at]
return max(stamps) if stamps else None
@property
def observed_label(self) -> str:
return self.observed_at.strftime("%d %b %Y") if self.observed_at else "—"
# ---------- scan selection ----------
@cached_property
def runs(self) -> list[Scan]:
rows = (
self.session.execute(
select(Scan)
.where(Scan.target_id == self.target.id)
.order_by(Scan.created_at.desc())
.limit(60)
)
.scalars()
.all()
)
return list(rows)
def scan_for(self, dimension: str) -> UUID | None:
"""The run whose rows this dimension is reported from."""
if self.is_scan_scope:
return self.scan.id if self.scan else None
key = f"scan_for:{dimension}"
if key in self._memo:
return self._memo[key]
table = _TABLE[dimension]
row = self.session.execute(
select(table.scan_id, func.max(Scan.created_at).label("at"))
.join(Scan, Scan.id == table.scan_id)
.where(table.target_id == self.target.id)
.group_by(table.scan_id)
.order_by(func.max(Scan.created_at).desc())
.limit(1)
).first()
found = row[0] if row else None
self._memo[key] = found
return found
@cached_property
def previous_scan(self) -> Scan | None:
if self.scan is None:
return None
cutoff = self.scan.started_at or self.scan.created_at
return (
self.session.execute(
select(Scan)
.where(
Scan.target_id == self.target.id,
Scan.id != self.scan.id,
Scan.created_at < cutoff,
Scan.status == ScanStatus.COMPLETED.value,
)
.order_by(Scan.created_at.desc())
.limit(1)
)
.scalars()
.first()
)
@property
def cutoff(self) -> datetime | None:
if self.scan is not None:
return self.scan.started_at or self.scan.created_at
return self.observed_at
# ---------- coverage ----------
@cached_property
def planned_stages(self) -> dict[str, dict]:
if self.scan is None:
return {}
config = self.scan.execution_config or {}
return {
name: values
for name, values in (config.get("stages") or {}).items()
if (values or {}).get("enabled", True)
}
@cached_property
def producing_stages(self) -> dict[str, set[str]]:
"""Dimension -> the enabled stages that produce it."""
try:
from stages.registry import stages as stage_specs # noqa: PLC0415
except ImportError:
return {}
specs = {spec.name: spec for spec in stage_specs()}
out: dict[str, set[str]] = {dim: set() for dim in SURFACE_ORDER}
for name in self.planned_stages:
spec = specs.get(name)
if spec is None:
continue
for dimension, kinds in SURFACE_KINDS.items():
if spec.produces & kinds:
out[dimension].add(name)
return out
def _count(self, dimension: str, scan_id: UUID | None) -> int:
if scan_id is None:
return 0
table = _TABLE[dimension]
query = select(func.count()).select_from(table).where(table.scan_id == scan_id)
if dimension == _DIM.VULNERABILITIES.value:
query = query.where(self._not_suppressed())
return int(self.session.execute(query).scalar() or 0)
@cached_property
def coverage(self) -> dict[str, DimensionCoverage]:
out: dict[str, DimensionCoverage] = {}
for dimension in SURFACE_ORDER:
scan_id = self.scan_for(dimension)
count = self._count(dimension, scan_id)
planned = bool(self.producing_stages.get(dimension))
covered = count > 0 or (planned and self.scan is not None)
observed = None
if scan_id is not None:
run = next((r for r in self.runs if r.id == scan_id), self.scan)
if run is not None:
observed = run.completed_at or run.started_at or run.created_at
entry = DimensionCoverage(
dimension=dimension,
covered=covered,
count=count,
observed_at=observed,
scan_id=str(scan_id) if scan_id else "",
)
if not covered:
entry.note = "No run has produced this."
out[dimension] = entry
self._attach_previous(out)
return out
def _attach_previous(self, coverage: dict[str, DimensionCoverage]) -> None:
previous = self.previous_scan
if previous is None:
return
covered_before = self._covered_by(previous)
for dimension, entry in coverage.items():
if not entry.covered or dimension not in covered_before:
continue
entry.previous = self._count(dimension, previous.id)
def _covered_by(self, scan: Scan) -> set[str]:
"""Rows are the only proof a previous run produced a dimension, so only they anchor a delta."""
return {d for d in SURFACE_ORDER if self._count(d, scan.id)}
@cached_property
def covered_dimensions(self) -> frozenset[str]:
return frozenset(d for d, c in self.coverage.items() if c.covered)
def count_of(self, dimension: str) -> int:
return self.coverage[dimension].count
# ---------- baselines ----------
def _baseline_keys(self, dimension: str) -> set[tuple]:
key = f"baseline:{dimension}"
if key in self._memo:
return self._memo[key]
table = _TABLE[dimension]
columns = _KEY[dimension]
cutoff = self.cutoff
found: set[tuple] = set()
if cutoff is not None:
scan_id = self.scan_for(dimension)
query = (
select(*columns)
.distinct()
.where(table.target_id == self.target.id, table.discovered_at < cutoff)
)
if scan_id is not None:
query = query.where(table.scan_id != scan_id)
found = {tuple(row) for row in self.session.execute(query)}
self._memo[key] = found
return found
def has_baseline(self, dimension: str) -> bool:
return bool(self._baseline_keys(dimension))
# ---------- vulnerabilities ----------
@staticmethod
def _not_suppressed():
suppressed = (
select(VulnerabilityTriage.id)
.where(
VulnerabilityTriage.target_id == Vulnerability.target_id,
VulnerabilityTriage.fingerprint == Vulnerability.fingerprint,
VulnerabilityTriage.state.in_(SUPPRESSED_STATES),
)
.exists()
)
return ~suppressed
@cached_property
def triage(self) -> dict[str, tuple[str, str | None]]:
rows = self.session.execute(
select(
VulnerabilityTriage.fingerprint,
VulnerabilityTriage.state,
VulnerabilityTriage.note,
).where(VulnerabilityTriage.target_id == self.target.id)
).all()
return {row[0]: (row[1], row[2]) for row in rows}
@cached_property
def findings(self) -> list[Finding]:
scan_id = self.scan_for(_DIM.VULNERABILITIES.value)
if scan_id is None:
return []
rows = (
self.session.execute(
select(Vulnerability)
.where(Vulnerability.scan_id == scan_id)
.limit(MAX_REPORT_ROWS)
)
.scalars()
.all()
)
baseline = self._baseline_keys(_DIM.VULNERABILITIES.value)
known = self.has_baseline(_DIM.VULNERABILITIES.value)
assets = self._asset_context({r.host for r in rows if r.host})
out: list[Finding] = []
for row in rows:
state, note = self.triage.get(row.fingerprint, ("open", None))
if state in SUPPRESSED_STATES:
continue
asset = assets.get(row.host or "")
out.append(
Finding(
id=str(row.id),
fingerprint=row.fingerprint,
template_id=row.template_id,
name=row.template_name,
severity=row.severity,
scanner=row.scanner,
protocol=row.protocol,
matched_at=row.matched_at,
host=row.host,
ip=row.ip,
port=row.port,
url=row.url,
description=row.description,
impact=row.impact,
remediation=row.remediation,
references=list(row.references or []),
tags=list(row.tags or []),
cve_ids=list(row.cve_ids or []),
cwe_ids=list(row.cwe_ids or []),
cvss_score=row.cvss_score,
cvss_metrics=row.cvss_metrics,
epss_score=row.epss_score,
is_kev=row.is_kev,
state=state,
note=note,
is_new=known and (row.fingerprint,) not in baseline,
extracted=[str(v) for v in (row.extracted_results or [])],
request=row.request,
response=row.response,
curl=row.curl_command,
matcher=row.matcher_name,
discovered_at=row.discovered_at,
screenshot=asset[0] if asset else None,
asset_title=asset[1] if asset else None,
asset_status=asset[2] if asset else None,
asset_tech=list(asset[3]) if asset else [],
)
)
out.sort(
key=lambda f: (
severity_rank(f.severity),
not f.is_kev,
-(f.epss_score or 0),
)
)
return out
def _asset_context(self, hosts: set[str]) -> dict[str, tuple]:
if not hosts:
return {}
scan_id = self.scan_for(_DIM.WEB_ASSETS.value)
if scan_id is None:
return {}
rows = self.session.execute(
select(
Subdomain.name,
Subdomain.screenshot_path,
Subdomain.page_title,
Subdomain.http_status,
Subdomain.tech,
).where(
Subdomain.scan_id == scan_id, Subdomain.name.in_(list(hosts)[:2000])
)
).all()
return {row[0]: (row[1], row[2], row[3], row[4] or []) for row in rows}
@cached_property
def coverage_rows(self) -> list[VulnerabilityCoverage]:
scan_id = self.scan_for(_DIM.VULNERABILITIES.value)
if scan_id is None:
return []
return list(
self.session.execute(
select(VulnerabilityCoverage)
.where(VulnerabilityCoverage.scan_id == scan_id)
.order_by(VulnerabilityCoverage.started_at)
)
.scalars()
.all()
)
@cached_property
def severity_counts(self) -> dict[str, int]:
counts = dict.fromkeys((s.value for s in Severity), 0)
for finding in self.findings:
counts[finding.severity] = counts.get(finding.severity, 0) + 1
return counts
@cached_property
def suppressed_count(self) -> int:
scan_id = self.scan_for(_DIM.VULNERABILITIES.value)
if scan_id is None:
return 0
total = int(
self.session.execute(
select(func.count())
.select_from(Vulnerability)
.where(Vulnerability.scan_id == scan_id)
).scalar()
or 0
)
return max(0, total - len(self.findings))
# ---------- hosts ----------
@cached_property
def host_rows(self) -> list[Host]:
scan_id = self.scan_for(_DIM.WEB_ASSETS.value)
if scan_id is None:
return []
newest = (
select(HttpAsset)
.where(HttpAsset.scan_id == scan_id)
.distinct(HttpAsset.host)
.order_by(HttpAsset.host, HttpAsset.discovered_at.desc())
.subquery()
)
asset = aliased(HttpAsset, newest)
rows = self.session.execute(
select(Subdomain, asset)
.outerjoin(asset, newest.c.host == Subdomain.name)
.where(Subdomain.scan_id == scan_id)
.order_by(Subdomain.http_status.is_(None), Subdomain.name)
.limit(MAX_REPORT_ROWS)
).all()
baseline = self._baseline_keys(_DIM.WEB_ASSETS.value)
known = self.has_baseline(_DIM.WEB_ASSETS.value)
by_host = self.findings_by_host
out: list[Host] = []
for sub, asset in rows:
worst = by_host.get(sub.name)
out.append(
Host(
name=sub.name,
status=sub.http_status,
title=sub.page_title,
url=sub.http_url,
ips=list(sub.resolved_ips or []),
cname=sub.cname,
tech=list((asset.tech if asset else None) or sub.tech or []),
webserver=sub.webserver,
is_cdn=bool(asset.is_cdn if asset else sub.is_cdn),
cdn_name=(asset.cdn_name if asset else None) or sub.cdn_name,
cdn_type=asset.cdn_type if asset else None,
waf=(asset.waf if asset else None) or sub.waf,
asn=sub.asn or (asset.asn if asset else None),
asn_org=sub.asn_org or (asset.asn_org if asset else None),
tls_issuer=(asset.tls_issuer_org or asset.tls_issuer_cn)
if asset
else None,
tls_not_after=(asset.tls_not_after if asset else None)
or sub.tls_not_after,
tls_expired=(asset.tls_expired if asset else None)
if asset
else sub.tls_expired,
tls_self_signed=sub.tls_self_signed,
screenshot=sub.screenshot_path,
sources=list(sub.sources or []),
is_new=known and (sub.name,) not in baseline,
findings=worst[0] if worst else 0,
worst=worst[1] if worst else None,
)
)
return out
@cached_property
def findings_by_host(self) -> dict[str, tuple[int, str]]:
out: dict[str, tuple[int, str]] = {}
for finding in self.findings:
key = finding.host or ""
if not key:
continue
count, worst = out.get(key, (0, Severity.UNKNOWN.value))
best = (
worst
if severity_rank(worst) <= severity_rank(finding.severity)
else finding.severity
)
out[key] = (count + 1, best)
return out
@cached_property
def live_hosts(self) -> list[Host]:
return [h for h in self.host_rows if h.status]
# ---------- addresses ----------
@cached_property
def address_rows(self) -> list[Address]:
scan_id = self.scan_for(_DIM.IPS.value)
if scan_id is None:
return []
rows = (
self.session.execute(
select(IpAddress)
.where(IpAddress.scan_id == scan_id)
.order_by(IpAddress.ip)
.limit(MAX_REPORT_ROWS)
)
.scalars()
.all()
)
ports = self._ports_per_ip()
hosts = self._hosts_per_ip()
baseline = self._baseline_keys(_DIM.IPS.value)
known = self.has_baseline(_DIM.IPS.value)
return [
Address(
ip=row.ip,
version=row.version,
asn=row.asn,
asn_org=row.asn_org,
country=row.country,
prefix=row.prefix,
is_cdn=row.is_cdn,
cdn_name=row.cdn_name,
cdn_type=row.cdn_type,
scan_policy=row.scan_policy,
scan_policy_reason=row.scan_policy_reason,
ptr=list(row.ptr_hostnames or []),
open_ports=ports.get(row.ip, 0),
hosts=hosts.get(row.ip, []),
is_new=known and (row.ip,) not in baseline,
)
for row in rows
]
def _ports_per_ip(self) -> dict[str, int]:
scan_id = self.scan_for(_DIM.SERVICES.value)
if scan_id is None:
return {}
rows = self.session.execute(
select(Port.ip, func.count())
.where(Port.scan_id == scan_id)
.group_by(Port.ip)
).all()
return {row[0]: int(row[1]) for row in rows}
def _hosts_per_ip(self) -> dict[str, list[str]]:
scan_id = self.scan_for(_DIM.WEB_ASSETS.value)
if scan_id is None:
return {}
rows = self.session.execute(
select(HttpAsset.ip, HttpAsset.host)
.where(HttpAsset.scan_id == scan_id, HttpAsset.ip.is_not(None))
.distinct()
.limit(20000)
).all()
out: dict[str, list[str]] = {}
for ip, host in rows:
bucket = out.setdefault(ip, [])
if len(bucket) < _MAX_HOSTS_PER_IP and host not in bucket:
bucket.append(host)
return out
# ---------- services ----------
@cached_property
def service_rows(self) -> list[Service]:
scan_id = self.scan_for(_DIM.SERVICES.value)
if scan_id is None:
return []
rows = self.session.execute(
select(Port, IpAddress)
.outerjoin(
IpAddress,
and_(IpAddress.scan_id == Port.scan_id, IpAddress.ip == Port.ip),
)
.where(Port.scan_id == scan_id)
.order_by(Port.ip, Port.number)
.limit(MAX_REPORT_ROWS)
).all()
hosts = self._hosts_per_ip()
baseline = self._baseline_keys(_DIM.SERVICES.value)
known = self.has_baseline(_DIM.SERVICES.value)
return [
Service(
ip=port.ip,
port=port.number,
protocol=port.protocol,
service_name=port.service_name,
service_class=port.service_class,
product=port.product,
version=port.version,
banner=port.banner,
is_http=port.is_http,
tls=port.tls,
source=port.source,
sensitive=port.number in SENSITIVE_PORTS,
hosts=hosts.get(port.ip, []),
asn_org=address.asn_org if address else None,
country=address.country if address else None,
is_new=known and (port.ip, port.number, port.protocol) not in baseline,
)
for port, address in rows
]
@cached_property
def sensitive_services(self) -> list[Service]:
return [s for s in self.service_rows if s.sensitive]
# ---------- endpoints ----------
@cached_property
def endpoint_rows(self) -> list[EndpointRow]:
scan_id = self.scan_for(_DIM.ENDPOINTS.value)
if scan_id is None:
return []
rows = (
self.session.execute(
select(Endpoint)
.where(Endpoint.scan_id == scan_id)
.order_by(Endpoint.host, Endpoint.path)
.limit(MAX_REPORT_ROWS)
)
.scalars()
.all()
)
baseline = self._baseline_keys(_DIM.ENDPOINTS.value)
known = self.has_baseline(_DIM.ENDPOINTS.value)
return [
EndpointRow(
url=row.url,
host=row.host,
path=row.path,
status=row.status_code,
endpoint_class=row.endpoint_class,
content_type=row.content_type,
length=row.content_length,
title=row.title,
params=list(row.params or []),
interest=list(row.interest or []),
sources=list(row.sources or []),
is_probed=row.is_probed,
is_new=known and (row.signature,) not in baseline,
)
for row in rows
]
# ---------- certificates ----------
@cached_property
def certificates(self) -> list[Certificate]:
scan_id = self.scan_for(_DIM.WEB_ASSETS.value)
if scan_id is None:
return []
rows = self.session.execute(
select(
HttpAsset.host,
HttpAsset.tls_subject_cn,
HttpAsset.tls_issuer_org,
HttpAsset.tls_issuer_cn,
HttpAsset.tls_not_before,
HttpAsset.tls_not_after,
HttpAsset.tls_expired,
HttpAsset.tls_self_signed,
HttpAsset.tls_sans,
HttpAsset.tls_version,
)
.where(HttpAsset.scan_id == scan_id, HttpAsset.tls_not_after.is_not(None))
.distinct(HttpAsset.host)
.order_by(HttpAsset.host, HttpAsset.id)
.limit(MAX_REPORT_ROWS)
).all()
now = self.observed_at or utc_now()
out: list[Certificate] = []
for row in rows:
expires = row[5]
days = None
if expires is not None:
try:
days = (expires - now).days
except TypeError:
days = None
out.append(
Certificate(
host=row[0],
subject=row[1],
issuer=row[2] or row[3],
not_before=row[4],
not_after=expires,
expired=row[6],
self_signed=row[7],
days_left=days,
sans=len(row[8] or []),
version=row[9],
)
)
return out
# ---------- rollups ----------
def _facet(
self, column, scan_id: UUID | None, table, limit: int = 12
) -> list[Facet]:
if scan_id is None:
return []
rows = self.session.execute(
select(column, func.count())
.where(table.scan_id == scan_id, column.is_not(None), column != "")
.group_by(column)
.order_by(func.count().desc())
.limit(limit)
).all()
return [Facet(name=str(row[0]), count=int(row[1])) for row in rows]
@cached_property
def countries(self) -> list[Facet]:
return self._facet(IpAddress.country, self.scan_for(_DIM.IPS.value), IpAddress)
@cached_property
def networks(self) -> list[Facet]:
return self._facet(IpAddress.asn_org, self.scan_for(_DIM.IPS.value), IpAddress)
@cached_property
def webservers(self) -> list[Facet]:
return self._facet(
HttpAsset.webserver, self.scan_for(_DIM.WEB_ASSETS.value), HttpAsset
)
@cached_property
def technologies(self) -> list[Facet]:
scan_id = self.scan_for(_DIM.WEB_ASSETS.value)
if scan_id is None:
return []
rows = self.session.execute(
text(
"SELECT v AS value, count(DISTINCT a.host) AS c "
"FROM http_assets a, LATERAL jsonb_array_elements_text(cast(a.tech AS jsonb)) v "
"WHERE a.scan_id = :sid GROUP BY v ORDER BY c DESC, v ASC LIMIT 14"
),
{"sid": str(scan_id)},
).all()
return [Facet(name=str(row[0]), count=int(row[1])) for row in rows]
@cached_property
def status_classes(self) -> list[Facet]:
scan_id = self.scan_for(_DIM.WEB_ASSETS.value)
if scan_id is None:
return []
bucket = case(
(Subdomain.http_status.between(200, 299), "2xx"),
(Subdomain.http_status.between(300, 399), "3xx"),
(Subdomain.http_status.between(400, 499), "4xx"),
(Subdomain.http_status >= _SERVER_ERROR, "5xx"),
else_="none",
)
rows = self.session.execute(
select(bucket, func.count())
.where(Subdomain.scan_id == scan_id)
.group_by(bucket)
.order_by(bucket)
).all()
return [Facet(name=str(row[0]), count=int(row[1])) for row in rows]
@cached_property
def service_classes(self) -> list[Facet]:
scan_id = self.scan_for(_DIM.SERVICES.value)
if scan_id is None:
return []
rows = self.session.execute(
select(Port.service_class, func.count())
.where(Port.scan_id == scan_id)
.group_by(Port.service_class)
.order_by(func.count().desc())
).all()
order = [c.value for c in ServiceClass]
facets = [Facet(name=str(row[0]), count=int(row[1])) for row in rows]
facets.sort(key=lambda f: order.index(f.name) if f.name in order else 99)
return facets
@cached_property
def top_services(self) -> list[Facet]:
scan_id = self.scan_for(_DIM.SERVICES.value)
if scan_id is None:
return []
rows = self.session.execute(
select(Port.service_name, func.count())
.where(Port.scan_id == scan_id, Port.service_name.is_not(None))
.group_by(Port.service_name)
.order_by(func.count().desc())
.limit(12)
).all()
return [Facet(name=str(row[0]), count=int(row[1])) for row in rows]
@cached_property
def cdn_split(self) -> tuple[int, int, int]:
"""Edge, cloud, direct, across resolving hosts."""
scan_id = self.scan_for(_DIM.WEB_ASSETS.value)
if scan_id is None:
return (0, 0, 0)
rows = self.session.execute(
select(HttpAsset.cdn_type, func.count(distinct(HttpAsset.host)))
.where(HttpAsset.scan_id == scan_id)
.group_by(HttpAsset.cdn_type)
).all()
edge = cloud = direct = 0
for kind, count in rows:
if kind in {"cdn", "waf"}:
edge += int(count)
elif kind == "cloud":
cloud += int(count)
else:
direct += int(count)
return (edge, cloud, direct)
# ---------- run detail ----------
@cached_property
def stage_runs(self) -> list[StageRun]:
if self.scan is None:
return []
rows = (
self.session.execute(
select(ScanActivity)
.where(ScanActivity.scan_id == self.scan.id)
.order_by(ScanActivity.created_at)
)
.scalars()
.all()
)
try:
from stages.registry import stages as stage_specs # noqa: PLC0415
specs = {spec.name: spec for spec in stage_specs()}
except ImportError:
specs = {}
out: list[StageRun] = []
for row in rows:
name = getattr(row, "stage_name", None) or getattr(row, "name", "")
spec = specs.get(name)
started = getattr(row, "started_at", None) or row.created_at
ended = getattr(row, "completed_at", None) or getattr(row, "ended_at", None)
duration = None
if started and ended:
duration = (ended - started).total_seconds()
out.append(
StageRun(
name=name,
title=spec.title if spec else name.replace("_", " ").title(),
status=str(getattr(row, "status", "")),
started_at=started,
ended_at=ended,
duration_seconds=duration,
counts=getattr(row, "counts", None) or {},
warnings=list(getattr(row, "warnings", None) or []),
error=getattr(row, "error", None),
)
)
return out
@cached_property
def tools_used(self) -> list[str]:
try:
from stages.registry import stages as stage_specs # noqa: PLC0415
except ImportError:
return []
specs = {spec.name: spec for spec in stage_specs()}
names: set[str] = set()
for stage in self.planned_stages:
spec = specs.get(stage)
if spec:
names.update(spec.tools)
return sorted(names)
def trend(self, dimension: str, limit: int = 8) -> list[int]:
table = _TABLE[dimension]
rows = self.session.execute(
select(table.scan_id, func.count(), func.max(Scan.created_at).label("at"))
.join(Scan, Scan.id == table.scan_id)
.where(table.target_id == self.target.id)
.group_by(table.scan_id)
.order_by(func.max(Scan.created_at).desc())
.limit(limit)
).all()
return [int(row[1]) for row in reversed(rows)]
def added_and_gone(
self, dimension: str, limit: int = 40
) -> tuple[list[str], list[str], int, int]:
"""What this run holds that the previous did not, and the other way round."""
previous = self.previous_scan
scan_id = self.scan_for(dimension)
if previous is None or scan_id is None:
return ([], [], 0, 0)
table = _TABLE[dimension]
columns = _KEY[dimension]
label = _LABEL[dimension]
service = dimension == _DIM.SERVICES.value
def read(scan: UUID) -> dict[tuple, str]:
rows = self.session.execute(
select(*columns, label).where(table.scan_id == scan).distinct()
)
out: dict[tuple, str] = {}
for row in rows:
key = tuple(row[: len(columns)])
out[key] = f"{row[-1]}:{key[1]}" if service else str(row[-1])
return out
current = read(scan_id)
before = read(previous.id)
added = [v for k, v in current.items() if k not in before]
gone = [v for k, v in before.items() if k not in current]
return (sorted(added)[:limit], sorted(gone)[:limit], len(added), len(gone))
def excluded(self) -> dict[str, list[str]]:
config = (self.scan.execution_config or {}) if self.scan else {}
return {
"hosts": list(config.get("excluded_subdomains") or []),
"addresses": list(config.get("excluded_ips") or []),
"paths": list(config.get("excluded_paths") or []),
}
def has_any(self, *dimensions: str) -> bool:
return any(self.count_of(d) for d in dimensions)
@cached_property
def origin_candidates(self) -> list[tuple[str, str, str, list[str]]]:
"""Addresses answering directly that share an identity with a CDN-fronted hostname."""
scan_id = self.scan_for(_DIM.WEB_ASSETS.value)
if scan_id is None:
return []
rows = self.session.execute(
select(
HttpAsset.host,
HttpAsset.ip,
HttpAsset.cdn_type,
HttpAsset.content_hash,
HttpAsset.tls_fingerprint,
HttpAsset.favicon_hash,
HttpAsset.status_code,
).where(
HttpAsset.scan_id == scan_id,
HttpAsset.status_code.is_not(None),
)
).all()
fronted: dict[tuple[str, str], set[str]] = {}
direct: dict[tuple[str, str], set[str]] = {}
for host, ip, cdn_type, content, tls, favicon, _status in rows:
for kind, value in (("body", content), ("tls", tls), ("favicon", favicon)):
if not value:
continue
bucket = fronted if cdn_type in {"cdn", "waf"} else direct
bucket.setdefault((kind, value), set()).add(
host if bucket is fronted else (ip or host)
)
out: list[tuple[str, str, str, list[str]]] = []
seen: set[str] = set()
for key, addresses in direct.items():
hosts = fronted.get(key)
if not hosts:
continue
for address in sorted(addresses):
if address in seen:
continue
seen.add(address)
out.append((address, key[0], key[1][:16], sorted(hosts)[:6]))
return out[:40]