Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md

This commit is contained in:
2026-09-24 13:07:21 -04:00
commit ea70a9cb5f
38 changed files with 9604 additions and 0 deletions
+376
View File
@@ -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,
)