"""Rule-based risk scoring. Runs on every item, always, with no API key and no network beyond what the scan already fetched. Two jobs: 1. Standalone verdict when the Claude layer is off or unavailable. 2. A second opinion shown next to the Claude verdict, so a disagreement between them is visible rather than hidden. The scale is 0 (install it) to 100 (do not touch it). Weights are tuned for a live production environment where an unusable device on Sunday morning costs far more than running three weeks behind. """ from __future__ import annotations from ..config import PolicyConfig from ..models import Evidence, Item, Verdict, VersionCheck from ..versions import parse def _days(n: float) -> str: """'1 day' / '5 days' -- this text goes out in an email to the team.""" whole = int(round(n)) return f"{whole} day" if whole == 1 else f"{whole} days" # Points added for the worst severity seen in community evidence. _SEVERITY_POINTS = {"brick": 45.0, "crash": 26.0, "regression": 13.0, "annoyance": 4.0, "info": 0.0} # Points by size of the version jump. _BUMP_POINTS = {"major": 34.0, "minor": 14.0, "patch": 5.0, "none": 0.0, "unknown": 8.0} _CRITICALITY_MULT = {"showstopper": 1.35, "high": 1.15, "normal": 1.0, "low": 0.85} GO_BELOW = 25.0 AVOID_ABOVE = 55.0 def soak_days(item: Item, policy: PolicyConfig) -> int: if item.criticality == "showstopper": return policy.soak_days_showstopper if item.criticality == "high": return policy.soak_days_high return policy.soak_days_normal def score( item: Item, check: VersionCheck, comparison: str, bump: str, evidence: list[Evidence], policy: PolicyConfig, first_seen_age_days: float | None = None, ) -> Verdict: reasons: list[str] = [] cited: list[str] = [] # -- cases where "should we install it?" is the wrong question -------- if item.update_class == "do_not_touch": return Verdict( recommendation="avoid", confidence="high", engine="heuristic", headline="Not the AV team's to update", reasons=[ "Marked do_not_touch: life-safety, rigging or building " "systems are updated by the licensed contractor only, and a " "self-installed update can void the listing that makes them " "compliant.", "Tracked here for visibility, never for action.", ], risk_score=100.0, heuristic_score=100.0, heuristic_recommendation="avoid", ) if item.update_class == "vendor_pushed": return Verdict( recommendation="manual-check", confidence="medium", engine="heuristic", headline="Updates arrive whether or not you approve them", reasons=[ "Marked vendor_pushed: the supplier or app store updates this " "on their schedule, so there is no install decision to make.", "What matters is advance warning of a breaking change, not a " "go/no-go. Watch for deprecation notices and keep a way back.", ], risk_score=0.0, heuristic_score=0.0, heuristic_recommendation="manual-check", ) if item.update_class == "expiry": detail = (f" Expires {item.expires}." if item.expires else " No expiry date recorded -- add one.") return Verdict( recommendation="manual-check", confidence="medium", engine="heuristic", headline="Fails on a date, not on a version", reasons=[ "Marked expiry: this is a licence, certificate or contract. " "Version tracking does not apply." + detail, ], risk_score=0.0, heuristic_score=0.0, heuristic_recommendation="manual-check", ) if not item.is_confirmed: return Verdict( recommendation="manual-check", confidence="low", engine="heuristic", headline="Make and model not confirmed yet", reasons=[ "This row is a placeholder AVWatch inferred from the rest of " "the fleet, not something you confirmed. No update advice is " "given for it, because advice based on a guessed product is " "worse than none.", "Fill in the real make and model and set status: confirmed.", ], risk_score=0.0, heuristic_score=0.0, heuristic_recommendation="manual-check", ) # -- cases where there is nothing to recommend ------------------------ if item.freeze: return Verdict( recommendation="avoid", confidence="high", engine="heuristic", headline="Frozen by local policy", reasons=["Marked freeze: true in inventory -- deliberately held at " "its current version."], risk_score=100.0, heuristic_score=100.0, heuristic_recommendation="avoid", ) if check.status == "unconfigured": return Verdict( recommendation="manual-check", confidence="low", engine="heuristic", headline="Version could not be checked this run", reasons=[ check.detail or "The version lookup is not configured.", "This item is tracked -- AVWatch just could not read its " "version. It is not a gap in the inventory.", ], risk_score=0.0, heuristic_score=0.0, heuristic_recommendation="manual-check", ) if check.status == "manual": return Verdict( recommendation="manual-check", confidence="low", engine="heuristic", headline="Needs a human to check the version", reasons=[check.detail or "No machine-readable version feed for this vendor."], risk_score=0.0, heuristic_score=0.0, heuristic_recommendation="manual-check", ) if check.status == "error": return Verdict( recommendation="manual-check", confidence="low", engine="heuristic", headline="Version check failed", reasons=[check.detail or "The source could not be read this run."], risk_score=0.0, heuristic_score=0.0, heuristic_recommendation="manual-check", ) if comparison == "current": return Verdict( recommendation="go", confidence="high", engine="heuristic", headline="Already up to date", reasons=[f"Running {item.installed}, which is the latest release."], risk_score=0.0, heuristic_score=0.0, heuristic_recommendation="go", ) if comparison == "ahead": return Verdict( recommendation="manual-check", confidence="medium", engine="heuristic", headline="Installed version is newer than the latest public release", reasons=[ f"Inventory says {item.installed} but the newest public release is " f"{check.latest}. Either a beta is installed or the inventory is stale.", ], risk_score=10.0, heuristic_score=10.0, heuristic_recommendation="manual-check", ) if comparison == "unknown": if not item.installed: return Verdict( recommendation="manual-check", confidence="low", engine="heuristic", headline=f"Latest is {check.latest}; installed version unknown", reasons=[ "AVWatch has no record of what is running on this item, so " "it cannot say whether you are behind.", f"Record it once with: avwatch set-installed {item.id} " "", ], risk_score=0.0, heuristic_score=0.0, heuristic_recommendation="manual-check", ) return Verdict( recommendation="manual-check", confidence="low", engine="heuristic", headline="Cannot compare versions", reasons=[ f"Installed {item.installed!r} and latest {check.latest!r} " "cannot be compared -- at least one is not a version number. " "Fix the 'installed' value in inventory.yaml.", ], risk_score=0.0, heuristic_score=0.0, heuristic_recommendation="manual-check", ) # -- we are behind; score the risk of moving -------------------------- total = _BUMP_POINTS.get(bump, 8.0) if bump == "major": reasons.append(f"Major version jump ({item.installed} to {check.latest}).") elif bump == "minor": reasons.append(f"Minor version bump ({item.installed} to {check.latest}).") elif bump == "patch": reasons.append(f"Patch-level update ({item.installed} to {check.latest}).") # A x.0 release is the least-tested build a vendor ships. latest = parse(check.latest) if latest.valid and len(latest.parts) >= 2 and latest.parts[1] == 0 and \ (len(latest.parts) < 3 or latest.parts[2] == 0): total += 10.0 reasons.append( f"{check.latest} is a .0 release -- historically the buggiest build " "of any cycle." ) # -- soak time --------------------------------------------------------- required = soak_days(item, policy) age = check.age_days if age is None: age = first_seen_age_days if age is not None: reasons.append( f"No release date published; using the {_days(age)} since AVWatch " "first saw this version." ) if age is None: total += 8.0 reasons.append("Release age unknown, so it cannot be credited for soak time.") suggested_wait = required elif age < required: shortfall = (required - age) / max(required, 1) total += 30.0 * shortfall suggested_wait = int(round(required - age)) reasons.append( f"Only {_days(age)} old; policy wants {required} days of soak time for " f"{item.criticality}-criticality gear ({_days(suggested_wait)} more)." ) else: total -= 8.0 suggested_wait = None reasons.append( f"Released {_days(age)} ago, past the {required}-day soak window." ) # -- community evidence ------------------------------------------------ # Evidence naming this release is treated as real; evidence that merely # mentions the product is background noise. Every actively developed # product has open crash reports at all times, and counting those as # evidence against a specific release makes the score meaningless. negatives = [e for e in evidence if e.sentiment == "negative"] positives = [e for e in evidence if e.sentiment == "positive"] specific = [e for e in negatives if e.mentions_version] generic = [e for e in negatives if not e.mentions_version] ladder = ["info", "annoyance", "regression", "crash", "brick"] labels = {"brick": "devices being bricked", "crash": "crashes or signal loss", "regression": "regressions", "annoyance": "minor issues"} def worst_of(bucket: list[Evidence]) -> str: w = "info" for e in bucket: if ladder.index(e.severity) > ladder.index(w): w = e.severity return w worst_specific = worst_of(specific) if worst_specific != "info": total += _SEVERITY_POINTS[worst_specific] reasons.append( f"{len(specific)} report(s) naming {check.latest} specifically " f"mention {labels[worst_specific]}." ) cited.extend(e.url for e in specific if e.url) if len(specific) >= 3: total += min(12.0, 3.0 * (len(specific) - 2)) reasons.append( f"{len(specific)} independent reports about this release, " "not a single complaint." ) elif generic: # Discounted heavily and labelled honestly. worst_generic = worst_of(generic) if worst_generic != "info": total += _SEVERITY_POINTS[worst_generic] * 0.25 reasons.append( f"{len(generic)} report(s) mention {labels[worst_generic]} for this " f"product but none name {check.latest}, so this is background " "noise rather than evidence about this release." ) cited.extend(e.url for e in generic[:3] if e.url) if positives and not specific: total -= 10.0 reasons.append( f"{len(positives)} report(s) of it working, with nothing negative " f"naming {check.latest}." ) cited.extend(e.url for e in positives if e.url) if not evidence: total += 4.0 reasons.append( "No community discussion found -- absence of reports is weak evidence, " "not a clean bill of health." ) # -- release notes signal --------------------------------------------- notes = (check.release_notes or "").lower() if notes: if any(k in notes for k in ("security", "vulnerability", "cve-")): total -= 12.0 reasons.append("Release notes mention a security fix, which raises the " "cost of waiting.") if any(k in notes for k in ("critical fix", "data loss", "important update")): total -= 8.0 reasons.append("Vendor flags this as an important or critical fix.") if "known issue" in notes: total += 6.0 reasons.append("Vendor's own notes list known issues.") # -- criticality weighting -------------------------------------------- mult = _CRITICALITY_MULT.get(item.criticality, 1.0) total *= mult if item.criticality == "showstopper": reasons.append( "Scored conservatively: this is marked showstopper, so a failure " "here takes the service down." ) elif mult > 1.0: reasons.append( f"Scored conservatively because this is marked " f"{item.criticality} criticality." ) if policy.major_bump_needs_human and bump == "major" and item.is_showstopper: reasons.append( "Major jump on show-critical gear: schedule it with a rollback plan " "and a rehearsal, do not do it mid-week." ) total = max(0.0, min(100.0, total)) if total < GO_BELOW: rec = "go" headline = f"Safe to update to {check.latest}" elif total <= AVOID_ABOVE: rec = "wait" headline = f"Hold off on {check.latest} for now" else: rec = "avoid" headline = f"Do not install {check.latest} yet" confidence = "high" if len(evidence) >= 4 else ("medium" if evidence else "low") return Verdict( recommendation=rec, confidence=confidence, engine="heuristic", headline=headline, reasons=reasons, risk_score=round(total, 1), heuristic_score=round(total, 1), heuristic_recommendation=rec, cited=list(dict.fromkeys(cited))[:8], suggested_wait_days=suggested_wait if rec != "go" else None, )