""" 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) crit_factor = _criticality_factor(asset.criticality) 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 * crit_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)