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>
192 lines
7.7 KiB
Python
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
|