Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
"""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} "
|
||||
"<version>",
|
||||
],
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user