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.
138 lines
5.7 KiB
Python
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
|