Files
rengine/api/app/services/vulnerability.py
T
Yogesh Ojha 9f1ee01c2f feat(vulnerability_scan): drop injection matches that only echo the request URL
An injection-echo template (command injection, RCE, SSTI, shellshock) proves
itself with a token echoed into the response body. A page that reflects the
requested URL into a canonical or og:url tag satisfies that matcher without a
command running. The guard drops such a match for those template classes only,
after URL-decoding both sides, and never for XSS or out-of-band checks. Each
drop is counted on the coverage row and shown in the coverage strip.
2026-09-25 08:22:04 +05:30

1454 lines
55 KiB
Python

from __future__ import annotations
from uuid import UUID
from sqlalchemy import Text, cast, distinct, func, not_, or_, select, text
from sqlalchemy.dialects.postgresql import JSONB, aggregate_order_by
from sqlalchemy.dialects.postgresql import array as pg_array
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.asset_query import (
NO_JIT,
STATEMENT_TIMEOUT,
QueryScope,
QuerySyntaxError,
ScopeLike,
build_leads,
build_vuln_groups,
compile_vuln_query,
parse_query,
query_error_for,
syntax_error,
vuln_corroborated_ids,
vuln_has_baseline,
vuln_state,
vuln_suppressed,
)
from app.services.scan_surface import ScanSurfaceService
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.evidence import Evidence
from shared.definitions.oast import bare_interaction
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,
Scanner,
Severity,
VulnState,
)
from shared.enums.scan import (
SCAN_LIVE_STATUSES,
SCAN_OPEN_STATUSES,
ScanStatus,
)
from shared.logging import get_logger
from shared.models.asset_query import QueryGroups, QueryLeads
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_surface import SurfaceSummary
from shared.models.software import SoftwareCve
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.surface_query import vulnerabilities as surface_vulns
from shared.services.vuln_templates import sets_for
from shared.utils.datetime import utc_now
from shared.utils.text import plural
logger = get_logger(__name__)
DROPPED_SAMPLE = 5
_SAMPLE_HOSTS = 3
_TEMPLATE_ROOTS = (OFFICIAL_ROOT, CUSTOM_ROOT)
_FACET_LIMIT = 30
_TOP_FINDINGS = 6
_TOP_HOSTS = 6
_EVIDENCE_PREVIEW = 400
_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
_severity_rank = surface_vulns.severity_rank
class VulnerabilityService:
def __init__(self, session: AsyncSession):
self.session = session
_context = staticmethod(surface_vulns.context)
_apply_filter = staticmethod(surface_vulns.apply_filter)
_order = staticmethod(surface_vulns.order)
_scoped = staticmethod(surface_vulns.scoped)
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=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))
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)
host_findings = await self._neighbours(scope, rows, assets)
peers, colocated = await self._corroborators(scope, rows)
replays = await self._replays(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),
replays=replays.get(row.id, 0),
host_findings=host_findings.get(row.host or "", {}),
)
)
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,
replays: int = 0,
host_findings: dict[str, int] | 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 {})
if evidence
else bare_interaction(row.interaction),
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,
evidence=_ladder(row.evidence, bool(corroborated_by)),
replayed_from_id=row.replayed_from_id,
replays=replays,
asset=asset,
host_findings=host_findings or {},
)
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=syntax_error(exc))
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])
host_findings = await self._neighbours(scope, [row], assets)
peers, colocated = await self._corroborators(scope, [row])
replays = await self._replays([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,
replays=replays.get(row.id, 0),
host_findings=host_findings.get(row.host or "", {}),
)
async def _replays(self, rows: list[Vulnerability]) -> dict[UUID, int]:
"""Findings confirmed on equivalent web assets, per source finding."""
ids = [r.id for r in rows]
if not ids:
return {}
found = await self.session.execute(
select(Vulnerability.replayed_from_id, func.count())
.where(Vulnerability.replayed_from_id.in_(ids))
.group_by(Vulnerability.replayed_from_id)
)
return {source: int(n) for source, n in found.all()}
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)
inferred = await self._inferred_at(scope, rows)
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 = [
Corroboration(
template_id=f"software:{name}:{version}:{cve}",
template_name=f"{name} {version} version inference",
severity=row.severity,
scanner=Scanner.RENGINE.value,
basis=CorroborationBasis.CVE.value,
shared=[cve],
)
for name, version, cve in inferred.get(row.host or "", [])
if cve in (row.cve_ids or [])
]
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 _inferred_at(
self, scope: QueryScope, rows: list[Vulnerability]
) -> dict[str, list[tuple[str, str, str]]]:
"""Software CVEs inferred on the hosts these findings sit on."""
hosts = sorted({r.host for r in rows if r.host})
if not hosts:
return {}
found = await self.session.execute(
select(
SoftwareCve.host, SoftwareCve.name, SoftwareCve.version, SoftwareCve.cve
)
.where(scope.match(SoftwareCve.scan_id), SoftwareCve.host.in_(hosts))
.distinct()
)
out: dict[str, list[tuple[str, str, str]]] = {}
for host, name, version, cve in found.all():
out.setdefault(host, []).append((name, version, cve))
return out
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]:
"""Hostnames each check fired on in this scan."""
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 _neighbours(
self,
scope: QueryScope,
rows: list[Vulnerability],
assets: dict[UUID, AssetContext],
) -> dict[str, dict[str, int]]:
"""Open findings per host by severity; ports, names, endpoints and software CVEs beside each asset."""
hosts = sorted({r.host for r in rows if r.host})
ips = sorted({r.ip for r in rows if r.ip})
by_host: dict[str, dict[str, int]] = {}
endpoints: dict[str, int] = {}
software: dict[str, int] = {}
if hosts:
found = await self.session.execute(
select(Vulnerability.host, Vulnerability.severity, func.count())
.where(
scope.match(Vulnerability.scan_id),
Vulnerability.host.in_(hosts),
not_(vuln_suppressed(scope)),
)
.group_by(Vulnerability.host, Vulnerability.severity)
)
for host, severity, n in found.all():
by_host.setdefault(host, {})[severity] = int(n)
found = await self.session.execute(
select(Endpoint.host, func.count())
.where(scope.match(Endpoint.scan_id), Endpoint.host.in_(hosts))
.group_by(Endpoint.host)
)
endpoints = {host: int(n) for host, n in found.all()}
found = await self.session.execute(
select(SoftwareCve.host, func.count(distinct(SoftwareCve.cve)))
.where(scope.match(SoftwareCve.scan_id), SoftwareCve.host.in_(hosts))
.group_by(SoftwareCve.host)
)
software = {host: int(n) for host, n in found.all()}
ports: dict[str, int] = {}
names: dict[str, int] = {}
if ips:
found = await self.session.execute(
select(
Port.ip,
func.count(distinct(func.concat(Port.number, Port.protocol))),
)
.where(scope.match(Port.scan_id), Port.ip.in_(ips))
.group_by(Port.ip)
)
ports = {ip: int(n) for ip, n in found.all()}
element = (
func.jsonb_array_elements_text(cast(Subdomain.resolved_ips, JSONB))
.table_valued("value")
.alias("resolved")
)
found = await self.session.execute(
select(element.c.value, func.count(distinct(Subdomain.name)))
.select_from(Subdomain)
.join(element, text("true"))
.where(
scope.match(Subdomain.scan_id),
cast(Subdomain.resolved_ips, JSONB).op("?|")(pg_array(ips)),
element.c.value.in_(ips),
)
.group_by(element.c.value)
)
names = {ip: int(n) for ip, n in found.all()}
for row in rows:
extra = {
"open_ports": ports.get(row.ip or "", 0),
"names_on_ip": names.get(row.ip or "", 0),
"endpoints": endpoints.get(row.host or "", 0),
"software_cves": software.get(row.host or "", 0),
}
if not any(extra.values()):
continue
asset = assets.get(row.id)
if asset is None:
assets[row.id] = AssetContext(
host=row.host, url=row.url, ip=row.ip, **extra
)
else:
for key, value in extra.items():
setattr(asset, key, value)
return by_host
async def seeds(
self, scope: ScopeLike, f: VulnerabilityFilter, limit: int
) -> list[tuple[str, UUID]]:
scope = QueryScope.of(scope)
now = utc_now()
base = self._scoped(
scope, f, columns=(Vulnerability.host, Vulnerability.scan_id)
)
try:
predicate = compile_vuln_query(
parse_query(f.q, VULN_QUERY), self._context(scope, 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.where(Vulnerability.host.isnot(None)).distinct().limit(limit)
)
return [(host, scan_id) for host, scan_id in rows.all()]
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,
tier=row.tier,
batch=row.batch,
hosts_covered=int(row.hosts_covered or 0),
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 [])[:DROPPED_SAMPLE],
hosts_dropped_count=len(row.hosts_dropped or []),
requests_sent=row.requests_sent,
matched=row.matched,
echo_filtered=int(row.echo_filtered or 0),
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)
surface = await ScanSurfaceService(self.session).summary(scope)
result = ScanVulnerabilities(
surface=surface if surface.planned else None,
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=_targets_reached(coverage, surface),
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, await self._open_status(scope) if not ran else None)
if total:
result.top_findings = await self._top_findings(scope, live)
result.top_hosts = await self._top_hosts(scope, live)
return result
async def _open_status(self, scope: QueryScope) -> str | None:
"""Paused, live or neither, for the runs the scope covers."""
if not scope.ids:
return None
states = set(
(
await self.session.execute(
select(Scan.status).where(
Scan.id.in_(scope.ids), Scan.status.in_(SCAN_OPEN_STATUSES)
)
)
)
.scalars()
.all()
)
if states & set(SCAN_LIVE_STATUSES):
return ScanStatus.RUNNING.value
return ScanStatus.PAUSED.value if states else None
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:
"""One decision per target the scope holds this finding on."""
scope = QueryScope.of(scope)
rows = (
(
await self.session.execute(
select(Vulnerability).where(
scope.match(Vulnerability.scan_id),
Vulnerability.fingerprint == fingerprint,
)
)
)
.scalars()
.all()
)
samples = {row.target_id: row for row in rows}
if not samples:
return None
existing = {
row.target_id: row
for row in (
await self.session.execute(
select(VulnerabilityTriage).where(
VulnerabilityTriage.target_id.in_(samples),
VulnerabilityTriage.fingerprint == fingerprint,
)
)
)
.scalars()
.all()
}
now = utc_now()
for target_id, sample in samples.items():
decision = existing.get(target_id)
if decision is None:
decision = VulnerabilityTriage(
project_id=sample.project_id,
target_id=target_id,
fingerprint=fingerprint,
template_id=sample.template_id,
matched_at=sample.matched_at,
)
self.session.add(decision)
existing[target_id] = decision
decision.state = data.state
decision.note = data.note
decision.decided_by = user_id
decision.updated_at = now
await self.session.commit()
await lead_cache.bump(tuple(samples))
affected = await self.session.scalar(
select(func.count()).where(
Vulnerability.target_id.in_(samples),
Vulnerability.fingerprint == fingerprint,
)
)
return TriageResult(
fingerprint=fingerprint,
state=data.state,
note=data.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()
await lead_cache.bump(targets)
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 _ladder(stored: str, corroborated: bool) -> str:
if stored == Evidence.PROVEN.value:
return Evidence.PROVEN.value
return Evidence.CORROBORATED.value if corroborated else Evidence.OBSERVED.value
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 _targets_reached(coverage: list[CoverageRead], surface: SurfaceSummary) -> int:
"""Targets a check reached, origins and the web assets they stand for."""
if surface.planned:
return surface.origins + surface.covered + surface.services + surface.names
return sum(row.hosts_total for row in coverage)
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:
"""Templates loaded, or selected when the scanner did not report."""
if row.templates_loaded is not None:
return row.templates_loaded
return row.templates_selected or 0
def _headline(result: ScanVulnerabilities, open_status: str | None = None) -> None:
"""Worst finding first, total last."""
counts = {item.severity: item.count for item in result.by_severity}
if not result.ran:
if open_status == ScanStatus.PAUSED.value:
result.headline = "Vulnerability scan not run"
result.headline_detail = "The scan is paused."
elif open_status is not None:
result.headline = "Vulnerability scan not run"
result.headline_detail = "The scan is still running."
else:
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(hosts, 'target')}"
if checks
else f"{hosts:,} {plural(hosts, 'target')} tested"
)
if result.kev_count:
n = result.kev_count
result.headline = f"{n} known-exploited {plural(n, 'finding')}"
result.headline_detail = "On the CISA Known Exploited Vulnerabilities list."
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(critical, 'finding')}"
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(high, 'finding')}"
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(n, 'finding')} 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(result.total, 'finding')}, 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}. Nothing matched."
result.headline_tone = "success"