Files
vulncheck/app/services/urs_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

336 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Unified Risk Score (URS) calculation.
Inputs:
AVS — Asset Vulnerability Score (0-100, from CVE/CPR data)
ASS — Asset Security Score (0-100, from SCA weighted score)
Output:
URS = round((AVS + ASS) / 2) × criticality_multiplier (capped 100)
Severity bands (per tester spec):
90-100 CRITICAL → eskalation
70-89 HIGH → 24-48 h
40-69 MEDIUM → 1-2 Wochen
1-39 LOW → routine
0 NONE
Defaults:
AVS hybrid = 0.7 × avg(CPR) + 0.3 × max(CPR)
ASS = avg weighted score across all SCA policies of the asset
impact-weighted when ComplianceImpact rows are loaded for
the policy's cis_ids, plain pass% otherwise.
Asset criticality multiplier:
low ×0.7 | normal ×1.0 | high ×1.3 | critical ×1.5
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Dict, Iterable, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.models.asset import Asset
from app.models.compliance import (
AssetRiskSnapshot, ComplianceCheck, ComplianceImpact, ComplianceResult,
)
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
logger = logging.getLogger(__name__)
# --------------------------------------------------------------
# Severity bands
# --------------------------------------------------------------
SEVERITY_BANDS = [
(90.0, 100.0, "CRITICAL"),
(70.0, 89.999, "HIGH"),
(40.0, 69.999, "MEDIUM"),
(0.001, 39.999, "LOW"),
(0.0, 0.0, "NONE"),
]
def urs_severity(urs: Optional[float]) -> str:
if urs is None:
return "NONE"
for lo, hi, label in SEVERITY_BANDS:
if lo <= urs <= hi:
return label
return "NONE"
# --------------------------------------------------------------
# Criticality multiplier
# --------------------------------------------------------------
_CRIT_FACTOR = {
"low": 0.7,
"normal": 1.0,
"high": 1.3,
"critical": 1.5,
}
def _criticality_factor(value: Optional[str]) -> float:
return _CRIT_FACTOR.get((value or "normal").lower(), 1.0)
# --------------------------------------------------------------
# AVS — Asset Vulnerability Score
# --------------------------------------------------------------
def compute_avs(
db: Session,
asset_id: int,
mode: str = "hybrid",
) -> Optional[float]:
"""
Compute AVS for one asset from its open vulnerabilities' CPR scores.
mode:
'hybrid' (default) → 0.7 × avg(CPR) + 0.3 × max(CPR)
'avg' → mean(CPR)
'max' → max(CPR)
"""
vulns = (
db.query(Vulnerability)
.filter(
Vulnerability.asset_id == asset_id,
Vulnerability.status == VulnerabilityStatus.open,
)
.all()
)
cprs = [v.calculate_cpr_score() for v in vulns]
cprs = [c for c in cprs if c is not None]
if not cprs:
return None
if mode == "avg":
return round(sum(cprs) / len(cprs), 2)
if mode == "max":
return round(max(cprs), 2)
# hybrid
avg = sum(cprs) / len(cprs)
return round(0.7 * avg + 0.3 * max(cprs), 2)
# --------------------------------------------------------------
# ASS — Asset Security Score (impact-weighted SCA)
# --------------------------------------------------------------
def _impact_lookup(
db: Session,
cis_ids: Iterable[str],
benchmarks: Iterable[str],
) -> Dict[str, int]:
"""
Bulk-fetch impacts for a set of cis_ids, falling back across
benchmarks. Returns {cis_id → impact}.
"""
cis_ids = list({c for c in cis_ids if c})
benchmarks = list({b for b in benchmarks if b})
if not cis_ids:
return {}
rows = (
db.query(ComplianceImpact)
.filter(ComplianceImpact.cis_id.in_(cis_ids))
.all()
)
result: Dict[str, int] = {}
benchmark_set = set(benchmarks)
# Prefer rows whose benchmark matches the asset's policies; else
# take the first hit (CSV cross-OS overlap).
for r in rows:
if r.cis_id not in result:
result[r.cis_id] = r.impact
if r.benchmark in benchmark_set:
result[r.cis_id] = r.impact
return result
def _weighted_score_for_policy(
db: Session,
result_row: ComplianceResult,
) -> Optional[float]:
"""
Recompute weighted_score for one ComplianceResult by inspecting its
cached ComplianceCheck rows (when present) and the loaded impacts.
Falls back to result_row.score when no checks cached or no impacts
match.
"""
checks: List[ComplianceCheck] = list(result_row.checks or [])
if not checks:
return result_row.score
impacts = _impact_lookup(
db,
(c.check_id for c in checks),
[result_row.policy_id],
)
if not impacts:
return result_row.score
total_impact = 0
earned_impact = 0
for c in checks:
imp = impacts.get(c.check_id, 50)
if (c.result or "").lower() in ("not applicable", "n/a"):
continue
total_impact += imp
if (c.result or "").lower() == "passed":
earned_impact += imp
if total_impact == 0:
return result_row.score
return round(100.0 * earned_impact / total_impact, 2)
def compute_ass(db: Session, asset_id: int) -> Tuple[Optional[float], int]:
"""
Compute ASS as the mean of (weighted_score OR score) across all
policy results of the asset. Returns (ass, policy_count).
"""
results = (
db.query(ComplianceResult)
.filter(ComplianceResult.asset_id == asset_id)
.all()
)
if not results:
return None, 0
scored: List[float] = []
for r in results:
# Refresh weighted_score if we have cached checks; else use it
# as-is or fall back to the simple score.
w = _weighted_score_for_policy(db, r)
if w != r.weighted_score:
r.weighted_score = w
chosen = w if w is not None else r.score
if chosen is not None:
scored.append(chosen)
if not scored:
return None, len(results)
return round(sum(scored) / len(scored), 2), len(results)
# --------------------------------------------------------------
# URS — Unified Risk Score
# --------------------------------------------------------------
def compute_urs(
db: Session,
asset_id: int,
avs_mode: str = "hybrid",
persist_snapshot: bool = True,
) -> Dict[str, Optional[float]]:
"""
Combine AVS + ASS into URS and optionally write a daily snapshot.
"""
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
return {"asset_id": asset_id, "avs": None, "ass": None, "urs": None,
"severity": "NONE", "criticality": None}
avs = compute_avs(db, asset_id, mode=avs_mode)
ass, policy_count = compute_ass(db, asset_id)
# Effective weighting = max(operator criticality, detected-role factor).
# A crown-jewel role (DC/ADCS → factor 1.5) raises the URS even when the
# operator left criticality at "normal"; the operator can still set a
# higher criticality, never a lower-than-role one.
crit_factor = _criticality_factor(asset.criticality)
try:
from app.services.risk_dimensions_service import risk_factor
role_factor = risk_factor(asset.high_value_score)
except Exception:
role_factor = 1.0
eff_factor = max(crit_factor, role_factor)
parts = [v for v in (avs, ass) if v is not None]
if not parts:
urs: Optional[float] = None
else:
base = sum(parts) / len(parts)
urs = min(round(base * eff_factor, 1), 100.0)
# Spec wants integer URS — round to nearest int but keep one
# decimal in storage so trend arrows can detect 0.5-point moves.
urs = round(urs, 1)
sev = urs_severity(urs)
if persist_snapshot:
_upsert_daily_snapshot(db, asset_id, avs, ass, urs, sev)
db.commit()
return {
"asset_id": asset_id,
"avs": avs,
"ass": ass,
"urs": urs,
"severity": sev,
"criticality": asset.criticality,
"criticality_factor": crit_factor,
"policy_count": policy_count,
}
def _upsert_daily_snapshot(
db: Session,
asset_id: int,
avs: Optional[float],
ass: Optional[float],
urs: Optional[float],
severity: str,
) -> None:
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
row = (
db.query(AssetRiskSnapshot)
.filter(
AssetRiskSnapshot.asset_id == asset_id,
AssetRiskSnapshot.snapshot_date == today,
)
.first()
)
if row is None:
row = AssetRiskSnapshot(asset_id=asset_id, snapshot_date=today)
db.add(row)
row.avs = avs
row.ass = ass
row.urs = urs
row.severity = severity
def compute_urs_for_all(
db: Session,
avs_mode: str = "hybrid",
) -> Dict[str, int]:
"""Recompute URS for every asset and persist daily snapshots."""
assets = db.query(Asset).all()
stats = {"assets": 0, "with_urs": 0, "errors": 0}
for a in assets:
try:
r = compute_urs(db, a.id, avs_mode=avs_mode, persist_snapshot=True)
stats["assets"] += 1
if r["urs"] is not None:
stats["with_urs"] += 1
except Exception as e:
stats["errors"] += 1
logger.warning("URS calc failed for asset=%s: %s", a.id, e)
return stats
def get_urs_trend(
db: Session,
asset_id: int,
days: int = 7,
) -> Optional[float]:
"""
Trend delta: today's URS minus URS `days` ago. Positive = degrading.
None when not enough history.
"""
snaps = (
db.query(AssetRiskSnapshot)
.filter(AssetRiskSnapshot.asset_id == asset_id)
.order_by(AssetRiskSnapshot.snapshot_date.desc())
.limit(days + 1)
.all()
)
if len(snaps) < 2 or snaps[0].urs is None:
return None
older = next((s.urs for s in snaps[1:] if s.urs is not None), None)
if older is None:
return None
return round(snaps[0].urs - older, 1)