Files
rengine/api/app/services/vulnerability.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

1303 lines
49 KiB
Python

from __future__ import annotations
from datetime import datetime
from uuid import UUID
from sqlalchemy import Text, case, cast, distinct, func, not_, or_, select, text
from sqlalchemy.dialects.postgresql import JSONB, aggregate_order_by
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.asset_query import (
NO_JIT,
STATEMENT_TIMEOUT,
QueryScope,
QuerySyntaxError,
ScopeLike,
VulnQueryContext,
build_leads,
build_vuln_groups,
compile_vuln_query,
parse_query,
query_error_for,
vuln_corroborated,
vuln_corroborated_ids,
vuln_has_baseline,
vuln_state,
vuln_suppressed,
)
from app.services.surface_scope import baselined_targets
from app.services.target_names import target_names
from shared.definitions.asset_query import COUNT_CAP, VULN_QUERY
from shared.definitions.vulnerabilities import (
ACTIONABLE_SEVERITIES,
CUSTOM_ROOT,
EPSS_HIGH,
OFFICIAL_ROOT,
PROTOCOL_LABELS,
SCANNER_LABELS,
SEVERITY_LABELS,
SEVERITY_ORDER,
VULN_STATE_LABELS,
VULN_STATES,
CorroborationBasis,
CoverageStatus,
Severity,
VulnState,
)
from shared.logging import get_logger
from shared.models.asset_query import QueryError, QueryGroups, QueryLeads
from shared.models.http_asset import HttpAsset
from shared.models.ip_address import IpAddress
from shared.models.scan import Scan
from shared.models.subdomain import Subdomain
from shared.models.vulnerability import (
AssetContext,
BulkTriageResult,
BulkTriageUpdate,
Corroboration,
CoverageRead,
IssuePage,
IssueRead,
RankedFinding,
ScanVulnerabilities,
SeverityCount,
TriageResult,
TriageUpdate,
Vulnerability,
VulnerabilityCoverage,
VulnerabilityFacets,
VulnerabilityFilter,
VulnerabilityPage,
VulnerabilityRead,
VulnerabilityTriage,
VulnFacet,
VulnHostRow,
)
from shared.services.asset_query import lead_cache
from shared.services.vuln_templates import sets_for
from shared.utils.datetime import utc_now
logger = get_logger(__name__)
_SAMPLE_HOSTS = 3
_TEMPLATE_ROOTS = (OFFICIAL_ROOT, CUSTOM_ROOT)
_FACET_LIMIT = 30
_TOP_FINDINGS = 6
_TOP_HOSTS = 6
_EVIDENCE_PREVIEW = 400
# fingerprints on this page an earlier scan of the same target already reported
_SEEN_SQL = """
SELECT DISTINCT e.fingerprint AS fingerprint
FROM vulnerabilities e
JOIN vulnerabilities cur ON cur.scan_id = ANY(:sids) AND cur.fingerprint = e.fingerprint
WHERE e.target_id = cur.target_id AND NOT (e.scan_id = ANY(:sids))
AND e.discovered_at < cur.discovered_at AND e.fingerprint = ANY(:fps)
"""
def _rank_of(severity: str) -> int:
try:
return SEVERITY_ORDER.index(severity)
except ValueError:
return len(SEVERITY_ORDER)
def _corroboration_basis(row, other) -> tuple[str, list[str]] | None:
shared = sorted(set(row.cve_ids or []) & set(other.cve_ids or []))
if shared:
return CorroborationBasis.CVE.value, shared
shared = sorted(set(row.cwe_ids or []) & set(other.cwe_ids or []))
if shared:
return CorroborationBasis.CWE.value, shared
return None
def _severity_rank():
return case(
{name: index for index, name in enumerate(SEVERITY_ORDER)},
value=Vulnerability.severity,
else_=len(SEVERITY_ORDER),
)
class VulnerabilityService:
def __init__(self, session: AsyncSession):
self.session = session
@staticmethod
def _context(scope: QueryScope, now: datetime) -> VulnQueryContext:
return VulnQueryContext(scope=scope, now=now)
@staticmethod
def _apply_filter(query, f: VulnerabilityFilter, scope: QueryScope):
if f.severities:
query = query.where(Vulnerability.severity.in_(f.severities))
elif not f.include_info:
query = query.where(Vulnerability.severity.in_(ACTIONABLE_SEVERITIES))
if f.protocols:
query = query.where(Vulnerability.protocol.in_(f.protocols))
if f.templates:
query = query.where(Vulnerability.template_id.in_(f.templates))
if f.hosts:
query = query.where(Vulnerability.host.in_(f.hosts))
if f.scanners:
query = query.where(Vulnerability.scanner.in_(f.scanners))
if f.tags:
query = query.where(
or_(
*[
func.jsonb_exists(cast(Vulnerability.tags, JSONB), tag)
for tag in f.tags
]
)
)
if f.kev:
query = query.where(Vulnerability.is_kev.is_(True))
if f.cve:
query = query.where(
func.jsonb_array_length(cast(Vulnerability.cve_ids, JSONB)) > 0
)
if f.new:
from app.services.asset_query import vuln_is_new # noqa: PLC0415
query = query.where(vuln_is_new(scope))
if f.corroborated:
query = query.where(vuln_corroborated(scope))
if f.states:
query = query.where(vuln_state(scope).in_(f.states))
elif not f.include_suppressed:
query = query.where(not_(vuln_suppressed(scope)))
return query
@staticmethod
def _order(query, f: VulnerabilityFilter, scope: QueryScope):
if f.sort == "risk":
# the finding first: severity, then what makes one severity outrank another
confirmed = vuln_corroborated_ids(scope).subquery()
return query.outerjoin(
confirmed, confirmed.c.id == Vulnerability.id
).order_by(
_severity_rank().asc(),
Vulnerability.is_kev.desc(),
Vulnerability.exploit_score.desc(),
confirmed.c.id.isnot(None).desc(),
Vulnerability.epss_score.desc().nulls_last(),
Vulnerability.cvss_score.desc().nulls_last(),
Vulnerability.template_id.asc(),
Vulnerability.matched_at.asc(),
)
column = {
"severity": _severity_rank(),
"name": Vulnerability.template_name,
"template": Vulnerability.template_id,
"host": Vulnerability.host,
"seen": Vulnerability.discovered_at,
"cvss": Vulnerability.cvss_score,
"epss": Vulnerability.epss_score,
"exploit": Vulnerability.exploit_score,
"type": Vulnerability.protocol,
}.get(f.sort, _severity_rank())
primary = column.desc() if f.order == "desc" else column.asc()
return query.order_by(
primary.nulls_last(),
Vulnerability.template_id.asc(),
Vulnerability.matched_at.asc(),
)
def _scoped(self, scope: QueryScope, f: VulnerabilityFilter, columns=None):
base = select(Vulnerability) if columns is None else select(*columns)
base = base.where(scope.match(Vulnerability.scan_id))
return self._apply_filter(base, f, scope)
async def search(
self, scope: ScopeLike, f: VulnerabilityFilter
) -> VulnerabilityPage:
scope = QueryScope.of(scope)
now = utc_now()
base = self._scoped(scope, f)
try:
predicate = compile_vuln_query(
parse_query(f.q, VULN_QUERY), self._context(scope, now)
)
except QuerySyntaxError as exc:
return VulnerabilityPage(
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))
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, scope).limit(f.limit).offset(f.offset)
)
)
.scalars()
.all()
)
except DBAPIError as exc:
await self.session.rollback()
rejected = query_error_for(exc)
if rejected is None:
raise
logger.info("vulnerability query rejected", error=str(exc.orig))
return VulnerabilityPage(error=rejected)
total = int(counted or 0)
capped = total > COUNT_CAP
page = VulnerabilityPage(
total=min(total, COUNT_CAP) if capped else total, total_capped=capped
)
if not rows:
return page
fingerprints = [row.fingerprint for row in rows]
baseline, seen = await self._seen_before(scope, fingerprints)
states = await self._states(scope, fingerprints)
spread = await self._spread(scope, [row.template_id for row in rows])
assets = await self._assets(scope, rows)
peers, colocated = await self._corroborators(scope, rows)
names = await target_names(self.session, (row.target_id for row in rows))
for row in rows:
decision = states.get((row.target_id, row.fingerprint))
page.items.append(
self._to_read(
row,
state=decision[0] if decision else VulnState.OPEN.value,
note=decision[1] if decision else None,
is_new=row.target_id in baseline and row.fingerprint not in seen,
host_count=spread.get(row.template_id, 1),
asset=assets.get(row.id),
corroborated_by=peers.get(row.id, []),
colocated=colocated.get(row.id, 0),
evidence=False,
target_value=names.get(row.target_id),
)
)
return page
def _to_read(
self,
row: Vulnerability,
*,
state: str,
note: str | None,
is_new: bool,
host_count: int,
asset: AssetContext | None,
evidence: bool,
corroborated_by: list[Corroboration] | None = None,
colocated: int = 0,
target_value: str | None = None,
) -> VulnerabilityRead:
return VulnerabilityRead(
id=row.id,
scan_id=row.scan_id,
target_id=row.target_id,
target_value=target_value,
fingerprint=row.fingerprint,
scanner=row.scanner,
template_id=row.template_id,
template_name=row.template_name,
template_path=row.template_path,
template_url=row.template_url,
severity=row.severity,
protocol=row.protocol,
matcher_name=row.matcher_name,
extractor_name=row.extractor_name,
extracted_results=list(row.extracted_results or []),
description=row.description,
impact=row.impact,
remediation=row.remediation,
references=list(row.references or []),
tags=list(row.tags or []),
authors=list(row.authors or []),
cve_ids=list(row.cve_ids or []),
cwe_ids=list(row.cwe_ids or []),
cvss_metrics=row.cvss_metrics,
cvss_score=row.cvss_score,
epss_score=row.epss_score,
epss_percentile=row.epss_percentile,
cpe=row.cpe,
is_kev=row.is_kev,
exploit_score=int(row.exploit_score or 0),
intel_kinds=list(row.intel_kinds or []),
kev_ransomware=bool(row.kev_ransomware),
kev_due_date=row.kev_due_date,
poc_count=row.poc_count,
template_available=row.template_available,
hackerone_reports=row.hackerone_reports,
matched_at=row.matched_at,
host=row.host,
ip=row.ip,
port=row.port,
scheme=row.scheme,
url=row.url,
path=row.path,
curl_command=row.curl_command,
request=row.request if evidence else _preview(row.request),
response=row.response if evidence else _preview(row.response),
interaction=dict(row.interaction or {}),
observed_at=row.observed_at,
discovered_at=row.discovered_at,
state=state,
note=note,
is_new=is_new,
host_count=host_count,
sets=_sets(row.tags, row.template_path),
corroborated_by=corroborated_by or [],
colocated=colocated,
asset=asset,
)
async def issues(self, scope: ScopeLike, f: VulnerabilityFilter) -> IssuePage:
"""One row per check that fired, over the same scope and filter as search()."""
scope = QueryScope.of(scope)
now = utc_now()
try:
predicate = compile_vuln_query(
parse_query(f.q, VULN_QUERY), self._context(scope, now)
)
except QuerySyntaxError as exc:
return IssuePage(
error=QueryError(
message=exc.message, hint=exc.hint, start=exc.start, end=exc.end
)
)
rank = _severity_rank()
state = vuln_state(scope)
baseline = await self.session.scalar(select(vuln_has_baseline(scope)))
confirmed = vuln_corroborated_ids(scope).subquery("vuln_confirmed")
columns = [
Vulnerability.template_id,
func.count().label("findings"),
func.count(distinct(Vulnerability.host)).label("hosts"),
func.count(distinct(Vulnerability.ip)).label("addresses"),
func.count(distinct(Vulnerability.matched_at)).label("locations"),
func.min(rank).label("rank"),
func.bool_or(Vulnerability.is_kev).label("kev"),
func.max(Vulnerability.cvss_score).label("cvss"),
func.max(Vulnerability.epss_score).label("epss"),
func.min(Vulnerability.discovered_at).label("first_seen"),
func.max(Vulnerability.discovered_at).label("last_seen"),
func.min(Vulnerability.template_name).label("name"),
func.min(cast(Vulnerability.id, Text)).label("sample"),
func.array_agg(
aggregate_order_by(distinct(Vulnerability.host), Vulnerability.host)
).label("host_list"),
func.count().filter(confirmed.c.id.isnot(None)).label("corroborated"),
]
if baseline:
from app.services.asset_query import vuln_is_new # noqa: PLC0415
columns.append(func.count().filter(vuln_is_new(scope)).label("new"))
for name in VULN_STATES:
columns.append(func.count().filter(state == name).label(f"state_{name}"))
base = self._scoped(scope, f, columns=columns).outerjoin(
confirmed, confirmed.c.id == Vulnerability.id
)
if predicate is not None:
base = base.where(predicate)
grouped = base.group_by(Vulnerability.template_id)
counted_sq = grouped.subquery()
order = {
"severity": counted_sq.c.rank,
"name": counted_sq.c.name,
"template": counted_sq.c.template_id,
"host": counted_sq.c.hosts,
"seen": counted_sq.c.first_seen,
"cvss": counted_sq.c.cvss,
"epss": counted_sq.c.epss,
"findings": counted_sq.c.findings,
}
if f.sort == "risk":
ordering = [
counted_sq.c.rank.asc(),
counted_sq.c.kev.desc(),
counted_sq.c.corroborated.desc(),
counted_sq.c.epss.desc().nulls_last(),
counted_sq.c.cvss.desc().nulls_last(),
counted_sq.c.hosts.desc(),
counted_sq.c.template_id.asc(),
]
else:
column = order.get(f.sort, counted_sq.c.rank)
primary = column.desc() if f.order == "desc" else column.asc()
ordering = [primary.nulls_last(), counted_sq.c.template_id.asc()]
await self.session.execute(text(STATEMENT_TIMEOUT))
await self.session.execute(text(NO_JIT))
try:
total = int(
await self.session.scalar(
select(func.count()).select_from(
select(counted_sq.c.template_id).limit(COUNT_CAP + 1).subquery()
)
)
or 0
)
rows = (
await self.session.execute(
select(counted_sq)
.order_by(*ordering)
.limit(f.limit)
.offset(f.offset)
)
).all()
except DBAPIError as exc:
await self.session.rollback()
rejected = query_error_for(exc)
if rejected is None:
raise
logger.info("vulnerability issues rejected", error=str(exc.orig))
return IssuePage(error=rejected)
capped = total > COUNT_CAP
page = IssuePage(
total=min(total, COUNT_CAP) if capped else total, total_capped=capped
)
if not rows:
return page
samples = {
row.id: row
for row in (
await self.session.execute(
select(Vulnerability).where(
Vulnerability.id.in_([UUID(r.sample) for r in rows])
)
)
).scalars()
}
for r in rows:
sample = samples.get(UUID(r.sample))
if sample is None:
continue
hosts = [h for h in (r.host_list or []) if h]
page.items.append(
IssueRead(
template_id=r.template_id,
template_name=sample.template_name,
template_url=sample.template_url,
scanner=sample.scanner,
severity=SEVERITY_ORDER[int(r.rank)]
if r.rank is not None and int(r.rank) < len(SEVERITY_ORDER)
else sample.severity,
protocol=sample.protocol,
tags=list(sample.tags or []),
sets=_sets(sample.tags, sample.template_path),
cve_ids=list(sample.cve_ids or []),
cwe_ids=list(sample.cwe_ids or []),
cvss_score=r.cvss,
epss_score=r.epss,
is_kev=bool(r.kev),
description=sample.description,
remediation=sample.remediation,
findings=int(r.findings),
hosts=int(r.hosts),
addresses=int(r.addresses),
locations=int(r.locations),
sample_hosts=hosts[:_SAMPLE_HOSTS],
corroborated=int(r.corroborated or 0),
new_count=int(getattr(r, "new", 0) or 0),
states={
name: int(getattr(r, f"state_{name}") or 0)
for name in VULN_STATES
if getattr(r, f"state_{name}")
},
first_seen=r.first_seen,
last_seen=r.last_seen,
sample_id=sample.id,
)
)
return page
async def get(self, scope: ScopeLike, vuln_id: UUID) -> VulnerabilityRead | None:
scope = QueryScope.of(scope)
row = await self.session.get(Vulnerability, vuln_id)
if row is None or row.scan_id not in scope.ids:
return None
baseline, seen = await self._seen_before(scope, [row.fingerprint])
states = await self._states(scope, [row.fingerprint])
spread = await self._spread(scope, [row.template_id])
assets = await self._assets(scope, [row])
peers, colocated = await self._corroborators(scope, [row])
decision = states.get((row.target_id, row.fingerprint))
return self._to_read(
row,
state=decision[0] if decision else VulnState.OPEN.value,
note=decision[1] if decision else None,
is_new=row.target_id in baseline and row.fingerprint not in seen,
host_count=spread.get(row.template_id, 1),
asset=assets.get(row.id),
corroborated_by=peers.get(row.id, []),
colocated=colocated.get(row.id, 0),
evidence=True,
)
async def _corroborators(
self, scope: QueryScope, rows: list[Vulnerability]
) -> tuple[dict[UUID, list[Corroboration]], dict[UUID, int]]:
"""Peers that confirm the same weakness, and how many findings share each location."""
locations = sorted({r.matched_at for r in rows})
if not locations:
return {}, {}
found = await self.session.execute(
select(
Vulnerability.id,
Vulnerability.matched_at,
Vulnerability.template_id,
Vulnerability.template_name,
Vulnerability.severity,
Vulnerability.scanner,
Vulnerability.cve_ids,
Vulnerability.cwe_ids,
).where(
scope.match(Vulnerability.scan_id),
Vulnerability.matched_at.in_(locations),
not_(vuln_suppressed(scope)),
)
)
at_location: dict[str, list] = {}
for other in found.all():
at_location.setdefault(other.matched_at, []).append(other)
peers: dict[UUID, list[Corroboration]] = {}
colocated: dict[UUID, int] = {}
for row in rows:
others = [o for o in at_location.get(row.matched_at, []) if o.id != row.id]
colocated[row.id] = len(others)
if row.severity == Severity.INFO.value:
continue
agreed = []
for other in others:
if other.severity == Severity.INFO.value:
continue
if other.template_id == row.template_id:
continue
basis = _corroboration_basis(row, other)
if basis is None:
continue
agreed.append(
Corroboration(
template_id=other.template_id,
template_name=other.template_name,
severity=other.severity,
scanner=other.scanner,
basis=basis[0],
shared=basis[1],
)
)
peers[row.id] = sorted(
agreed,
key=lambda c: (
_rank_of(c.severity),
c.scanner == row.scanner,
c.template_id,
),
)
return peers, colocated
async def _seen_before(
self, scope: QueryScope, fingerprints: list[str]
) -> tuple[set[UUID], set[str]]:
baseline = await baselined_targets(self.session, Vulnerability, scope)
if not baseline:
return set(), set()
rows = (
await self.session.execute(
text(_SEEN_SQL).bindparams(sids=list(scope.ids), fps=fingerprints)
)
).scalars()
return baseline, set(rows.all())
async def _states(
self, scope: QueryScope, fingerprints: list[str]
) -> dict[tuple[UUID, str], tuple[str, str | None]]:
targets = select(Scan.target_id).where(Scan.id.in_(scope.ids))
rows = await self.session.execute(
select(
VulnerabilityTriage.target_id,
VulnerabilityTriage.fingerprint,
VulnerabilityTriage.state,
VulnerabilityTriage.note,
).where(
VulnerabilityTriage.target_id.in_(targets),
VulnerabilityTriage.fingerprint.in_(fingerprints),
)
)
return {(target, fp): (state, note) for target, fp, state, note in rows.all()}
async def _spread(self, scope: QueryScope, templates: list[str]) -> dict[str, int]:
"""How many hostnames each check fired on in this scan — the finding's reach."""
rows = await self.session.execute(
select(
Vulnerability.template_id,
func.count(
func.distinct(
func.coalesce(Vulnerability.host, Vulnerability.matched_at)
)
),
)
.where(
scope.match(Vulnerability.scan_id),
Vulnerability.template_id.in_(templates),
)
.group_by(Vulnerability.template_id)
)
return {template: int(n) for template, n in rows.all()}
async def _assets(
self, scope: QueryScope, rows: list[Vulnerability]
) -> dict[UUID, AssetContext]:
hosts = sorted({r.host for r in rows if r.host})
ips = sorted({r.ip for r in rows if r.ip})
if not hosts and not ips:
return {}
by_host: dict[str, dict] = {}
if hosts:
found = await self.session.execute(
select(
Subdomain.name,
Subdomain.http_status,
Subdomain.page_title,
Subdomain.webserver,
Subdomain.tech,
Subdomain.screenshot_path,
Subdomain.is_cdn,
Subdomain.cdn_name,
Subdomain.waf,
).where(scope.match(Subdomain.scan_id), Subdomain.name.in_(hosts))
)
for row in found:
by_host[row.name] = dict(row._mapping)
by_ip: dict[str, dict] = {}
if ips:
found = await self.session.execute(
select(
IpAddress.ip,
IpAddress.asn,
IpAddress.asn_org,
IpAddress.country,
IpAddress.is_cdn,
IpAddress.cdn_name,
).where(scope.match(IpAddress.scan_id), IpAddress.ip.in_(ips))
)
for row in found:
by_ip[row.ip] = dict(row._mapping)
asset_ids = [r.http_asset_id for r in rows if r.http_asset_id]
by_asset: dict[UUID, dict] = {}
if asset_ids:
found = await self.session.execute(
select(
HttpAsset.id,
HttpAsset.url,
HttpAsset.status_code,
HttpAsset.title,
HttpAsset.webserver,
HttpAsset.tech,
HttpAsset.screenshot_path,
HttpAsset.is_cdn,
HttpAsset.cdn_name,
HttpAsset.waf,
).where(HttpAsset.id.in_(asset_ids))
)
for row in found:
by_asset[row.id] = dict(row._mapping)
out: dict[UUID, AssetContext] = {}
for row in rows:
asset = by_asset.get(row.http_asset_id) if row.http_asset_id else None
host = by_host.get(row.host or "") or {}
address = by_ip.get(row.ip or "") or {}
if not asset and not host and not address:
continue
source = asset or host
out[row.id] = AssetContext(
host=row.host,
url=(asset or {}).get("url") or row.url,
status_code=source.get("status_code") or source.get("http_status"),
title=source.get("title") or source.get("page_title"),
webserver=source.get("webserver"),
tech=list(source.get("tech") or []),
screenshot_path=source.get("screenshot_path"),
ip=row.ip,
asn=address.get("asn"),
asn_org=address.get("asn_org"),
country=address.get("country"),
is_cdn=bool(source.get("is_cdn") or address.get("is_cdn")),
cdn_name=source.get("cdn_name") or address.get("cdn_name"),
waf=source.get("waf"),
)
return out
async def leads(self, scope: ScopeLike, f: VulnerabilityFilter) -> QueryLeads:
scope = QueryScope.of(scope)
async def _build() -> QueryLeads:
now = utc_now()
base = self._scoped(scope, f, columns=(Vulnerability.id,))
ctx = self._context(scope, now)
await self.session.execute(text(STATEMENT_TIMEOUT))
await self.session.execute(text(NO_JIT))
try:
return await build_leads(
self.session,
base,
VULN_QUERY.examples,
lambda q: compile_vuln_query(parse_query(q, VULN_QUERY), ctx),
filtered=f.has_facets(),
)
except DBAPIError as exc:
await self.session.rollback()
logger.info("vulnerability leads failed", error=str(exc.orig))
return QueryLeads()
return await lead_cache.leads(
self.session,
dimension="vulnerabilities",
scans=scope.ids,
facets=lead_cache.facets_of(f),
build=_build,
)
async def groups(
self, scope: ScopeLike, f: VulnerabilityFilter, key: str
) -> QueryGroups:
scope = QueryScope.of(scope)
now = utc_now()
base = self._scoped(scope, f, columns=(Vulnerability.id,))
try:
predicate = compile_vuln_query(
parse_query(f.q, VULN_QUERY), self._context(scope, now)
)
except QuerySyntaxError:
return QueryGroups(dimension=key)
if predicate is not None:
base = base.where(predicate)
await self.session.execute(text(STATEMENT_TIMEOUT))
await self.session.execute(text(NO_JIT))
try:
return await build_vuln_groups(self.session, base, key, scope)
except DBAPIError as exc:
await self.session.rollback()
logger.info("vulnerability groups failed", error=str(exc.orig))
return QueryGroups(dimension=key)
async def facets(self, scope: ScopeLike) -> VulnerabilityFacets:
scope = QueryScope.of(scope)
reach = scope.match(Vulnerability.scan_id)
n = func.count()
async def tally(column, limit: int = _FACET_LIMIT):
rows = await self.session.execute(
select(column, n)
.where(reach, column.isnot(None), cast(column, Text) != "")
.group_by(column)
.order_by(n.desc(), column)
.limit(limit)
)
return [
VulnFacet(name=str(value), count=int(count))
for value, count in rows.all()
]
async def tally_array(column, limit: int = _FACET_LIMIT):
value = func.jsonb_array_elements_text(cast(column, JSONB)).column_valued(
"v"
)
rows = await self.session.execute(
select(value, n)
.select_from(Vulnerability)
.where(reach)
.group_by(value)
.order_by(n.desc(), value)
.limit(limit)
)
return [VulnFacet(name=str(v), count=int(c)) for v, c in rows.all()]
state = vuln_state(scope).label("state")
state_rows = await self.session.execute(
select(state, n).where(reach).group_by(state).order_by(n.desc())
)
severity = await tally(Vulnerability.severity, len(SEVERITY_ORDER))
worst = (
select(Vulnerability.template_id, func.min(_severity_rank()).label("rank"))
.where(reach)
.group_by(Vulnerability.template_id)
.subquery()
)
issue_rows = await self.session.execute(
select(worst.c.rank, func.count()).group_by(worst.c.rank)
)
issue_counts = {int(rank): int(count) for rank, count in issue_rows.all()}
def by_severity(counts: dict[str, int]) -> list[VulnFacet]:
return [
VulnFacet(
name=name, count=counts[name], label=SEVERITY_LABELS.get(name)
)
for name in SEVERITY_ORDER
if counts.get(name)
]
return VulnerabilityFacets(
severity=by_severity({f.name: f.count for f in severity}),
issue_severity=by_severity(
{
SEVERITY_ORDER[rank]: count
for rank, count in issue_counts.items()
if rank < len(SEVERITY_ORDER)
}
),
state=[
VulnFacet(
name=str(value),
count=int(count),
label=VULN_STATE_LABELS.get(str(value)),
)
for value, count in state_rows.all()
],
protocol=[
VulnFacet(name=f.name, count=f.count, label=PROTOCOL_LABELS.get(f.name))
for f in await tally(Vulnerability.protocol)
],
template=await tally(Vulnerability.template_id),
tag=await tally_array(Vulnerability.tags),
host=await tally(Vulnerability.host),
scanner=[
VulnFacet(name=f.name, count=f.count, label=SCANNER_LABELS.get(f.name))
for f in await tally(Vulnerability.scanner, 8)
],
)
async def coverage(self, scope: ScopeLike) -> list[CoverageRead]:
scope = QueryScope.of(scope)
rows = await self.session.execute(
select(VulnerabilityCoverage)
.where(scope.match(VulnerabilityCoverage.scan_id))
.order_by(VulnerabilityCoverage.started_at)
)
return [
CoverageRead(
id=row.id,
scanner=row.scanner,
group=row.group,
status=row.status,
severities=list(row.severities or []),
template_sets=list(row.template_sets or []),
templates_selected=row.templates_selected,
templates_loaded=row.templates_loaded,
custom_templates=row.custom_templates,
hosts_total=row.hosts_total,
hosts_scanned=row.hosts_scanned,
hosts_dropped=list(row.hosts_dropped or []),
requests_sent=row.requests_sent,
matched=row.matched,
errors=row.errors,
rate_limit=row.rate_limit,
duration_seconds=row.duration_seconds,
error=row.error,
)
for row in rows.scalars().all()
]
async def overview(self, scope: ScopeLike) -> ScanVulnerabilities:
scope = QueryScope.of(scope)
coverage = await self.coverage(scope)
ran = any(row.status != CoverageStatus.SKIPPED.value for row in coverage)
reach = scope.match(Vulnerability.scan_id)
live = not_(vuln_suppressed(scope))
counts = (
await self.session.execute(
select(Vulnerability.severity, func.count())
.where(reach, live)
.group_by(Vulnerability.severity)
)
).all()
by_severity = {name: int(count) for name, count in counts}
totals = (
await self.session.execute(
select(
func.count(),
func.count().filter(Vulnerability.is_kev.is_(True)),
func.count().filter(
func.jsonb_array_length(cast(Vulnerability.cve_ids, JSONB)) > 0
),
func.count(func.distinct(Vulnerability.host)),
func.count(func.distinct(Vulnerability.template_id)),
).where(reach, live)
)
).one()
suppressed = await self.session.scalar(
select(func.count()).where(reach, vuln_suppressed(scope))
)
new_count = await self._new_count(scope, live)
total = int(totals[0] or 0)
result = ScanVulnerabilities(
ran=ran,
total=total,
issues=int(totals[4] or 0),
actionable=sum(by_severity.get(s, 0) for s in ACTIONABLE_SEVERITIES),
new_count=new_count,
kev_count=int(totals[1] or 0),
cve_count=int(totals[2] or 0),
suppressed=int(suppressed or 0),
affected_hosts=int(totals[3] or 0),
by_severity=[
SeverityCount(
severity=name,
label=SEVERITY_LABELS[name],
count=by_severity.get(name, 0),
)
for name in SEVERITY_ORDER
if by_severity.get(name)
],
coverage=coverage,
scanned_hosts=sum(row.hosts_total for row in coverage),
templates_run=max((_ran(row) for row in coverage), default=0) or None,
requests_sent=_sum_or_none(row.requests_sent for row in coverage),
duration_seconds=sum(row.duration_seconds or 0 for row in coverage) or None,
)
_headline(result)
if total:
result.top_findings = await self._top_findings(scope, live)
result.top_hosts = await self._top_hosts(scope, live)
return result
async def _new_count(self, scope: QueryScope, live) -> int:
from app.services.asset_query import vuln_is_new # noqa: PLC0415
baseline = await self.session.scalar(select(vuln_has_baseline(scope)))
if not baseline:
return 0
return int(
await self.session.scalar(
select(func.count()).where(
scope.match(Vulnerability.scan_id), live, vuln_is_new(scope)
)
)
or 0
)
async def _top_findings(self, scope: QueryScope, live) -> list[RankedFinding]:
spread = (
select(
Vulnerability.template_id.label("template_id"),
func.count(
func.distinct(
func.coalesce(Vulnerability.host, Vulnerability.matched_at)
)
).label("hosts"),
func.min(_severity_rank()).label("rank"),
func.min(Vulnerability.id.cast(Text)).label("sample"),
)
.where(scope.match(Vulnerability.scan_id), live)
.group_by(Vulnerability.template_id)
.subquery()
)
rows = await self.session.execute(
select(Vulnerability, spread.c.hosts)
.join(
spread, Vulnerability.id == cast(spread.c.sample, Vulnerability.id.type)
)
.order_by(
spread.c.rank.asc(),
Vulnerability.is_kev.desc(),
spread.c.hosts.desc(),
Vulnerability.epss_score.desc().nulls_last(),
)
.limit(_TOP_FINDINGS)
)
items = list(rows.all())
baseline: set[UUID] = set()
seen: set[str] = set()
if items:
baseline, seen = await self._seen_before(
scope, [row[0].fingerprint for row in items]
)
out = []
for row, hosts in items:
is_new = row.target_id in baseline and row.fingerprint not in seen
signals = []
if row.is_kev:
signals.append("kev")
if (row.epss_score or 0) >= EPSS_HIGH:
signals.append("epss")
if is_new:
signals.append("new")
out.append(
RankedFinding(
id=row.id,
template_id=row.template_id,
name=row.template_name,
severity=row.severity,
host=row.host,
matched_at=row.matched_at,
host_count=int(hosts or 1),
is_kev=row.is_kev,
is_new=is_new,
cve_ids=list(row.cve_ids or []),
epss_score=row.epss_score,
signals=signals,
)
)
return out
async def _top_hosts(self, scope: QueryScope, live) -> list[VulnHostRow]:
rank = _severity_rank()
rows = await self.session.execute(
select(
Vulnerability.host,
func.count().label("total"),
func.min(rank).label("worst"),
func.count().filter(Vulnerability.severity == Severity.CRITICAL.value),
func.count().filter(Vulnerability.severity == Severity.HIGH.value),
func.count().filter(Vulnerability.severity == Severity.MEDIUM.value),
)
.where(
scope.match(Vulnerability.scan_id), live, Vulnerability.host.isnot(None)
)
.group_by(Vulnerability.host)
.order_by(func.min(rank).asc(), func.count().desc())
.limit(_TOP_HOSTS)
)
out = []
for host, total, worst, critical, high, medium in rows.all():
severity = (
SEVERITY_ORDER[int(worst)] if worst is not None else Severity.INFO.value
)
counts = [
SeverityCount(
severity=name, label=SEVERITY_LABELS[name], count=int(value)
)
for name, value in (
(Severity.CRITICAL.value, critical),
(Severity.HIGH.value, high),
(Severity.MEDIUM.value, medium),
)
if value
]
out.append(
VulnHostRow(
host=host,
total=int(total),
worst=severity,
counts=counts,
query=f'host="{host}"',
)
)
return out
async def triage(
self, scope: ScopeLike, fingerprint: str, data: TriageUpdate, user_id: UUID
) -> TriageResult | None:
scope = QueryScope.of(scope)
row = await self.session.scalar(
select(Vulnerability).where(
scope.match(Vulnerability.scan_id),
Vulnerability.fingerprint == fingerprint,
)
)
if row is None:
return None
existing = await self.session.scalar(
select(VulnerabilityTriage).where(
VulnerabilityTriage.target_id == row.target_id,
VulnerabilityTriage.fingerprint == fingerprint,
)
)
now = utc_now()
if existing is None:
existing = VulnerabilityTriage(
project_id=row.project_id,
target_id=row.target_id,
fingerprint=fingerprint,
template_id=row.template_id,
matched_at=row.matched_at,
)
self.session.add(existing)
existing.state = data.state
existing.note = data.note
existing.decided_by = user_id
existing.updated_at = now
await self.session.commit()
affected = await self.session.scalar(
select(func.count()).where(
Vulnerability.target_id == row.target_id,
Vulnerability.fingerprint == fingerprint,
)
)
return TriageResult(
fingerprint=fingerprint,
state=existing.state,
note=existing.note,
updated=int(affected or 0),
)
async def triage_many(
self, scope: ScopeLike, data: BulkTriageUpdate, user_id: UUID
) -> BulkTriageResult:
"""One decision for every observation of the named findings or checks."""
if not data.fingerprints and not data.template_ids:
return BulkTriageResult(state=data.state)
picks = []
if data.fingerprints:
picks.append(Vulnerability.fingerprint.in_(data.fingerprints))
if data.template_ids:
picks.append(Vulnerability.template_id.in_(data.template_ids))
rows = (
await self.session.execute(
select(Vulnerability).where(
QueryScope.of(scope).match(Vulnerability.scan_id), or_(*picks)
)
)
).scalars()
wanted = {(row.target_id, row.fingerprint): row for row in rows}
if not wanted:
return BulkTriageResult(state=data.state)
targets = {key[0] for key in wanted}
fingerprints = {key[1] for key in wanted}
existing = {
(row.target_id, row.fingerprint): row
for row in (
await self.session.execute(
select(VulnerabilityTriage).where(
VulnerabilityTriage.target_id.in_(targets),
VulnerabilityTriage.fingerprint.in_(fingerprints),
)
)
).scalars()
}
now = utc_now()
for key, sample in wanted.items():
decision = existing.get(key)
if decision is None:
decision = VulnerabilityTriage(
project_id=sample.project_id,
target_id=key[0],
fingerprint=key[1],
template_id=sample.template_id,
matched_at=sample.matched_at,
)
self.session.add(decision)
decision.state = data.state
decision.note = data.note
decision.decided_by = user_id
decision.updated_at = now
await self.session.commit()
affected = await self.session.scalar(
select(func.count()).where(
Vulnerability.target_id.in_(targets),
Vulnerability.fingerprint.in_(fingerprints),
)
)
return BulkTriageResult(
state=data.state,
fingerprints=len(fingerprints),
updated=int(affected or 0),
)
def _sets(tags, path: str | None) -> list[str]:
relative = path or ""
for root in _TEMPLATE_ROOTS:
if relative.startswith(root):
relative = relative[len(root) :]
break
return sets_for(list(tags or []), relative)
def _preview(value: str | None) -> str | None:
if not value:
return None
return value[:_EVIDENCE_PREVIEW]
def _sum_or_none(values) -> int | None:
known = [v for v in values if v is not None]
return sum(known) if known else None
def _ran(row) -> int:
"""What the scanner loaded, not what the plan asked for — a null is unknown, not zero."""
if row.templates_loaded is not None:
return row.templates_loaded
return row.templates_selected or 0
def _headline(result: ScanVulnerabilities) -> None:
"""Outlier first: the finding leads, the total is the fallback."""
counts = {item.severity: item.count for item in result.by_severity}
if not result.ran:
result.headline = "No vulnerability scan ran"
result.headline_detail = "Enable it on the engine, or add it at launch."
return
checks = result.templates_run or 0
hosts = result.scanned_hosts
account = (
f"{checks:,} checks ran against {hosts:,} {_plural('target', hosts)}"
if checks
else f"{hosts:,} {_plural('target', hosts)} tested"
)
if result.kev_count:
n = result.kev_count
result.headline = f"{n} known-exploited {'weakness is' if n == 1 else 'weaknesses are'} reachable"
result.headline_detail = (
"Listed as exploited in the wild. Treat as active risk."
)
result.headline_query = "is:kev"
result.headline_tone = "destructive"
return
critical = counts.get(Severity.CRITICAL.value, 0)
if critical:
result.headline = f"{critical} critical {_plural('finding', critical)}"
result.headline_detail = f"{account}."
result.headline_query = "severity:critical"
result.headline_tone = "destructive"
return
high = counts.get(Severity.HIGH.value, 0)
if high:
result.headline = f"{high} high-severity {_plural('finding', high)}"
result.headline_detail = f"{account}."
result.headline_query = "severity:high"
result.headline_tone = "warning"
return
if result.new_count:
n = result.new_count
result.headline = f"{n} new {_plural('finding', n)} since the previous scan"
result.headline_detail = f"{account}."
result.headline_query = "is:new"
result.headline_tone = "warning"
return
if result.total:
result.headline = (
f"{result.total} {_plural('finding', result.total)}, none above medium"
)
result.headline_detail = f"{account}."
result.headline_query = ""
result.headline_tone = "info"
return
result.headline = "No findings"
result.headline_detail = f"{account} and matched nothing."
result.headline_tone = "success"
def _plural(word: str, count: int) -> str:
return word if count == 1 else f"{word}s"