Files
vulncheck/app/services/exposure_service.py
T
vulncheckandClaude Opus 5 099bce723e fix(exposure): count a service once, not once per socket
Reading ESTABLISHED sockets as evidence (943569f) exposed a dedup key that
was too narrow: it included the protocol, which was harmless while only
listeners counted, but a busy host has many established sockets on the same
service port, and tcp vs tcp6 already made a single listener look like two
services.

The tester's domain controller listed "RDP :3389" four times and LDAP four
times. Since each additional entry adds 40% of its weight, the exposure score
inflated to 100 for what is one RDP and one LDAP service. Deduplicating by
port alone fixes both: one service, one entry, one weight.

Also feeds Recent Critical from four narrow server-side queries (critical,
high, KEV, EUVD) instead of filtering the 300 newest rows in the browser. A
batch of low-severity CVEs — Chrome publishes dozens at once — fills that
window completely and empties the widget, which no amount of extra depth
fixes; only filtering before the limit does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:49:42 +02:00

192 lines
7.7 KiB
Python

"""
Network-exposure scoring from Wazuh syscollector ports.
Tester request: a host running an exposed remote-control or legacy-
cleartext listener (VNC, RDP, Telnet, SMB, …) is network-vulnerabler
regardless of its CVE count. This service walks the open LISTENING
sockets per asset, classifies the risky ones, and computes a 0-100
exposure score stored on the asset as an extra risk dimension.
The score is informational — it does NOT auto-change asset
criticality (operator-owned). It's surfaced as a badge + a list of
the risky services so the operator can decide.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Dict, List, Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset
logger = logging.getLogger(__name__)
# port → (service label, base risk weight 0-40). Higher = worse to expose.
# Remote-control + cleartext-admin protocols rank highest.
#
# Weights rebalanced (tester: almost every Windows host hit 100 because
# baseline Windows services — SMB/MSRPC/NetBIOS/WinRM — were weighted like
# real exposures). Baseline Windows services are now LOW; genuine remote-
# control / cleartext-admin exposures stay HIGH. Crown-jewel ROLES (DC,
# ADCS, SQL, Exchange, …) are scored separately by risk_dimensions_service.
_RISKY_PORTS: Dict[int, tuple] = {
23: ("Telnet (cleartext)", 40),
3389: ("RDP", 30),
5900: ("VNC", 35), 5901: ("VNC", 35), 5902: ("VNC", 30),
5903: ("VNC", 30), 5904: ("VNC", 30), 5905: ("VNC", 30),
21: ("FTP (cleartext)", 25),
512: ("rexec", 30), 513: ("rlogin", 30), 514: ("rsh", 30),
27017: ("MongoDB", 24), 6379: ("Redis", 26), 9200: ("Elasticsearch", 20),
11211: ("Memcached", 24),
1433: ("MSSQL", 18), 3306: ("MySQL", 18), 5432: ("PostgreSQL", 18),
161: ("SNMP", 14),
2049: ("NFS", 16),
5985: ("WinRM-HTTP", 12), 5986: ("WinRM-HTTPS", 8),
445: ("SMB", 10),
389: ("LDAP (cleartext)", 8),
135: ("MSRPC", 6),
139: ("NetBIOS", 6),
22: ("SSH", 6),
8834: ("Nessus", 8),
}
# Listeners bound to these local IPs are NOT network-exposed.
_LOOPBACK_PREFIXES = ("127.", "::1", "0.0.0.0") # 0.0.0.0 = all-iface = exposed, handled below
def _is_externally_bound(local_ip: str) -> bool:
"""True if the listener is reachable from the network (not loopback)."""
ip = (local_ip or "").strip()
if not ip:
return True # unknown bind → assume exposed (conservative)
if ip.startswith("127.") or ip == "::1":
return False
return True # 0.0.0.0 / :: / real IP = exposed
def analyze_ports(ports: List[dict]) -> tuple:
"""Classify listening sockets → (exposure_score, services list).
services: [{port, proto, service, risk, local_ip}]
score: 0-100 — highest single risk + diminishing add-on for the rest.
"""
risky: List[dict] = []
seen: set = set()
for p in ports:
if not isinstance(p, dict):
continue
# Only listening sockets count as exposure.
state = str(p.get("state") or "").lower()
proto = str(p.get("protocol") or p.get("proto") or "").lower()
# A listener is the normal proof that a service is running. But Wazuh
# does not always report one: on a Windows Server 2025 DC the tester saw
# 3389 ESTABLISHED in netstat and no listening entry from syscollector
# at all, so RDP scored zero exposure on a box serving live RDP
# sessions. An ESTABLISHED socket whose LOCAL port is the well-known
# one is an inbound connection, which proves the service is there just
# as well. (Outbound connections carry an ephemeral local port, so they
# cannot be mistaken for this.)
if proto == "tcp" and state and state != "listening":
if state != "established":
continue
try:
port = int(p.get("local_port") or p.get("local", {}).get("port") or 0)
except (ValueError, TypeError):
continue
# An ESTABLISHED socket only counts when its LOCAL port is one of the
# known service ports — otherwise it is the ephemeral end of an
# outbound connection and proves nothing about this host.
if port <= 0 or port not in _RISKY_PORTS:
continue
local_ip = p.get("local_ip") or (p.get("local") or {}).get("ip") or ""
if not _is_externally_bound(local_ip):
continue
label, weight = _RISKY_PORTS[port]
# Deduplicate by PORT alone. It used to include the protocol, which was
# harmless while only listeners counted, but a busy host has many
# ESTABLISHED sockets on the same service port — and tcp vs tcp6 made
# even the listeners look like two services. The tester's DC listed
# "RDP :3389" four times and LDAP four times, and since every extra
# entry adds 40% of its weight, the exposure score inflated to 100 on
# what is really one RDP and one LDAP service.
if port in seen:
continue
seen.add(port)
risky.append({
"port": port,
"proto": proto or "tcp",
"service": label,
"risk": weight,
"local_ip": local_ip or "0.0.0.0",
"process": p.get("process") or "",
})
if not risky:
return 0.0, []
# Score: strongest listener at full weight, each additional one adds
# 40% of its weight, capped 100. So one VNC = 35; VNC+RDP+Telnet
# stacks toward 100 (clearly very exposed).
risky.sort(key=lambda r: r["risk"], reverse=True)
score = float(risky[0]["risk"])
for r in risky[1:]:
score += r["risk"] * 0.4
score = round(min(score, 100.0), 1)
return score, risky
def refresh_asset_exposure(db: Session, wazuh, asset: Asset) -> Optional[dict]:
"""Pull ports for one asset, compute + persist exposure. Returns
{score, count} or None when no agent / fetch failed."""
if not asset.wazuh_agent_id:
return None
try:
ports = wazuh.get_ports(asset.wazuh_agent_id) or []
except Exception as e:
logger.warning("exposure: ports fetch failed for %s: %s", asset.hostname, e)
return None
score, services = analyze_ports(ports)
asset.network_exposure_score = score
asset.exposed_services = json.dumps(services) if services else None
asset.exposure_updated_at = datetime.now()
# Risk Dimensions (crown-jewel roles) — reuse the ports already fetched
# plus the package list; no extra Wazuh round-trip beyond get_packages.
hv_score = 0.0
try:
from app.services.risk_dimensions_service import detect_risk_dimensions
try:
packages = wazuh.get_packages(asset.wazuh_agent_id) or []
except Exception:
packages = []
rd = detect_risk_dimensions(ports, packages)
hv_score = rd["score"]
asset.high_value_score = rd["score"]
asset.risk_dimensions = json.dumps(rd["dimensions"]) if rd["dimensions"] else None
asset.risk_dimensions_updated_at = datetime.now()
except Exception as e:
logger.warning("risk-dimensions failed for %s: %s", asset.hostname, e)
return {"score": score, "count": len(services), "high_value_score": hv_score}
def refresh_all_exposure(db: Session, wazuh) -> dict:
"""Walk every Wazuh-linked asset, refresh exposure. Returns stats."""
assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
stats = {"assets": 0, "exposed": 0, "errors": 0}
for asset in assets:
res = refresh_asset_exposure(db, wazuh, asset)
if res is None:
stats["errors"] += 1
continue
stats["assets"] += 1
if res["score"] > 0:
stats["exposed"] += 1
db.commit()
logger.info("exposure refresh: %s", stats)
return stats