Files
vulncheck/app/services/risk_dimensions_service.py
T
vulncheck c3a75e7a77 feat(risk): Asset Risk Dimensions — high-value-target scoring + exposure rebalance + URS
Tester: the port-based exposure score put nearly every Windows host at 100
(no separation), and the thing that actually matters — whether a host runs
a crown-jewel role enabling lateral movement / domain takeover — wasn't
captured.

- Migration 034 + model: assets.high_value_score (0-100) + risk_dimensions
  (JSON roles) + _updated_at.
- app/services/risk_dimensions_service.py: detect_risk_dimensions(ports,
  packages) → roles from syscollector ports (port + process) and installed
  packages: Domain Controller, ADCS/CA, backup servers, SW-distribution,
  Exchange, WSUS, MSSQL, DNS, DHCP, WinRM. Score = max(weight) + 0.3·rest
  (cap 100). risk_factor() maps it to a URS band (>=90→1.5 … else 1.0).
- exposure_service: rebalanced port weights — baseline Windows
  (SMB/MSRPC/NetBIOS/WinRM) now LOW; real remote-control/cleartext
  exposures (Telnet/VNC/RDP/FTP) stay HIGH. Risk detection runs in the same
  pass (reuses fetched ports + one get_packages call).
- urs_service: URS uses max(operator criticality factor, role factor) — a
  DC/ADCS host rises to critical weighting even at criticality=normal;
  operator can still set higher. criticality field untouched.
- assets API: high_value_score + risk_dimensions in the response + sortable;
  Assets page gets a "Risk" column with score + role badges.

Verified detection: DC(88+389)→100, SQL pkg+WinRM→79, plain Win→0,
Exchange+Veeam→100. Migration 034 required: alembic upgrade head.
Roles need Wazuh syscollector (ports+packages); Nessus/Intune-only → v2.
2026-06-16 13:23:16 +02:00

138 lines
5.7 KiB
Python

"""
Asset "Risk Dimensions" — high-value-target (crown-jewel) role detection.
Network exposure alone (open ports) doesn't capture WHY a host matters.
A Domain Controller, a Certificate Authority, a SQL/Exchange/backup server
— once compromised — enable lateral movement, domain takeover and ransomware
spread. This service detects such roles from the data Wazuh syscollector
already provides (listening ports + process names + installed packages) and
produces:
- a high_value_score (0-100)
- a list of detected role dimensions ({role, label, weight})
The score feeds the URS via risk_factor() (see urs_service): roles raise an
asset's risk weighting even when the operator left criticality at "normal".
Detection sources: ports (port number + process name) and installed package
names. Package-based roles (Exchange/WSUS/MSSQL/backup/SW-distribution) are
reliable; port/process roles (DC/DNS/DHCP/WinRM) are good. Deep NTLM/Kerberos
usage analysis is NOT available from syscollector → out of scope here.
"""
from __future__ import annotations
import re
from typing import Dict, List, Optional
# role key → (label, weight, port-set, process-substrings, package-substrings)
# A role fires if ANY of its port/process/package signals match.
_ROLES: List[dict] = [
{"role": "domain_controller", "label": "Domain Controller (AD DS)", "weight": 100,
"ports": {88, 464}, "ports_all": {389, 88}, # kerberos+ldap together = strong DC
"proc": ("ntds",), "pkg": ()},
{"role": "adcs", "label": "AD Certificate Services (CA)", "weight": 100,
"ports": set(), "proc": ("certsrv",),
"pkg": ("active directory certificate services", "certification authority")},
{"role": "backup", "label": "Backup server", "weight": 90,
"ports": set(), "proc": ("veeam", "acronis", "rubrik"),
"pkg": ("veeam", "acronis", "rubrik", "arcserve", "commvault", "netbackup",
"veritas backup", "altaro", "nakivo")},
{"role": "sw_distribution", "label": "Software distribution", "weight": 85,
"ports": set(), "proc": ("ccmexec",),
"pkg": ("configuration manager", "system center configuration", "endpoint configuration manager",
"configmgr", "pdq deploy", "bigfix", "ivanti")},
{"role": "exchange", "label": "Exchange (on-prem)", "weight": 85,
"ports": set(), "proc": ("msexchange",),
"pkg": ("microsoft exchange server",)},
{"role": "wsus", "label": "WSUS", "weight": 80,
"ports": {8530, 8531}, "proc": (),
"pkg": ("windows server update services", "wsus")},
{"role": "mssql", "label": "MS SQL Server", "weight": 70,
"ports": {1433}, "proc": ("sqlservr",),
"pkg": ("microsoft sql server 20", "sql server database engine")},
{"role": "dns", "label": "DNS server", "weight": 60,
"ports": {53}, "proc": ("dns.exe", "named", "dnsmasq"), "pkg": ()},
{"role": "dhcp", "label": "DHCP server", "weight": 55,
"ports": {67}, "proc": ("dhcpserver", "dhcpd"), "pkg": ("dhcp server",)},
{"role": "winrm", "label": "WinRM / PS-Remoting", "weight": 30,
"ports": {5985, 5986}, "proc": (), "pkg": ()},
]
def _norm(s: str) -> str:
return re.sub(r"\s+", " ", (s or "").lower()).strip()
def _listening_ports(ports: List[dict]) -> tuple:
"""Return (set_of_listening_ports, set_of_process_substrings_lower)."""
open_ports: set = set()
procs: set = set()
for p in ports or []:
if not isinstance(p, dict):
continue
state = str(p.get("state") or "").lower()
proto = str(p.get("protocol") or p.get("proto") or "").lower()
if proto == "tcp" and state and state != "listening":
continue
try:
port = int(p.get("local_port") or (p.get("local") or {}).get("port") or 0)
except (ValueError, TypeError):
continue
if port > 0:
open_ports.add(port)
proc = _norm(p.get("process") or "")
if proc:
procs.add(proc)
return open_ports, procs
def detect_risk_dimensions(ports: List[dict], packages: List[dict]) -> dict:
"""Detect crown-jewel roles → {score, dimensions:[{role,label,weight}]}.
Pure function (no DB / network) → unit-testable.
"""
open_ports, procs = _listening_ports(ports)
pkg_names = [_norm(p.get("name") or "") for p in (packages or []) if isinstance(p, dict)]
pkg_blob = " | ".join(pkg_names)
detected: List[dict] = []
for r in _ROLES:
hit = False
# ports: any of `ports`, OR all of `ports_all`
if r.get("ports") and (open_ports & r["ports"]):
hit = True
if not hit and r.get("ports_all") and r["ports_all"].issubset(open_ports):
hit = True
# process substrings
if not hit and r.get("proc"):
if any(any(sub in pr for pr in procs) for sub in r["proc"]):
hit = True
# package substrings
if not hit and r.get("pkg"):
if any(sub in pkg_blob for sub in r["pkg"]):
hit = True
if hit:
detected.append({"role": r["role"], "label": r["label"], "weight": r["weight"]})
if not detected:
return {"score": 0.0, "dimensions": []}
weights = sorted((d["weight"] for d in detected), reverse=True)
score = float(weights[0]) + 0.3 * sum(weights[1:])
score = round(min(score, 100.0), 1)
# surface highest-weight roles first
detected.sort(key=lambda d: d["weight"], reverse=True)
return {"score": score, "dimensions": detected}
def risk_factor(high_value_score: Optional[float]) -> float:
"""Map the high-value score to a URS multiplier band.
>=90 → 1.5 (critical), >=70 → 1.3 (high), >=40 → 1.15, else 1.0."""
s = high_value_score or 0.0
if s >= 90:
return 1.5
if s >= 70:
return 1.3
if s >= 40:
return 1.15
return 1.0