Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
"""Report generation: grouping, HTML rendering, plain-text digest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from ..models import Finding, RunSummary
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Groups:
|
||||
"""Findings bucketed the way a person reads them."""
|
||||
|
||||
avoid: list[Finding] = field(default_factory=list)
|
||||
wait: list[Finding] = field(default_factory=list)
|
||||
go_behind: list[Finding] = field(default_factory=list)
|
||||
manual: list[Finding] = field(default_factory=list)
|
||||
errors: list[Finding] = field(default_factory=list)
|
||||
current: list[Finding] = field(default_factory=list)
|
||||
ahead: list[Finding] = field(default_factory=list)
|
||||
# Latest version found, but we have no record of what is installed, so
|
||||
# no comparison is possible. Distinct from `manual`, where we could not
|
||||
# even find the latest version.
|
||||
no_baseline: list[Finding] = field(default_factory=list)
|
||||
# Deliberately held at their current version. Not news, so kept out of
|
||||
# the "do not install" section it would otherwise dominate.
|
||||
frozen: list[Finding] = field(default_factory=list)
|
||||
# AVWatch knows how to track these but the capability is switched off
|
||||
# (usually a missing API key). Distinct from `manual`, where no version
|
||||
# source exists at all -- conflating the two makes a config gap look
|
||||
# like the gear is untracked.
|
||||
needs_setup: list[Finding] = field(default_factory=list)
|
||||
|
||||
# Items whose installed versions already violate a dependency. These are
|
||||
# broken right now, not a future risk, so they lead the report.
|
||||
mismatched: list[Finding] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def actionable(self) -> list[Finding]:
|
||||
return self.avoid + self.wait + self.go_behind
|
||||
|
||||
|
||||
def group(findings: list[Finding]) -> Groups:
|
||||
g = Groups()
|
||||
for f in findings:
|
||||
rec = f.verdict.recommendation if f.verdict else "unknown"
|
||||
|
||||
# A live mismatch is a present-tense fault and gets its own section
|
||||
# at the top. Skip the normal buckets so the item is not listed twice
|
||||
# with two different framings.
|
||||
if any(c.severity == "blocking" and "MISMATCH NOW" in c.message
|
||||
for c in f.coupling):
|
||||
g.mismatched.append(f)
|
||||
continue
|
||||
|
||||
if f.item.freeze:
|
||||
g.frozen.append(f)
|
||||
elif f.check.status == "error":
|
||||
g.errors.append(f)
|
||||
elif f.check.status == "unconfigured":
|
||||
g.needs_setup.append(f)
|
||||
elif f.check.status == "manual":
|
||||
g.manual.append(f)
|
||||
elif f.check.latest and not f.item.installed:
|
||||
g.no_baseline.append(f)
|
||||
elif f.comparison == "current":
|
||||
g.current.append(f)
|
||||
elif f.comparison == "ahead":
|
||||
g.ahead.append(f)
|
||||
elif f.comparison == "behind":
|
||||
if rec == "avoid":
|
||||
g.avoid.append(f)
|
||||
elif rec == "wait":
|
||||
g.wait.append(f)
|
||||
elif rec == "go":
|
||||
g.go_behind.append(f)
|
||||
else:
|
||||
g.manual.append(f)
|
||||
else:
|
||||
g.manual.append(f)
|
||||
return g
|
||||
|
||||
|
||||
def engine_label(findings: list[Finding]) -> str:
|
||||
engines = {f.verdict.engine for f in findings if f.verdict}
|
||||
claude = sorted(e for e in engines if e.startswith("claude"))
|
||||
if claude:
|
||||
model = claude[0].split("(")[-1].rstrip(")") if "(" in claude[0] else "Claude"
|
||||
return f"Claude ({model}) with rule-based cross-check"
|
||||
return "rule-based heuristics only (no API key configured)"
|
||||
|
||||
|
||||
def _env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
autoescape=select_autoescape(["html", "jinja"]),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
def _tiles(run: RunSummary, g: Groups) -> list[tuple[str, int, str]]:
|
||||
return [
|
||||
("Behind", run.updates_found, "#8a6100"),
|
||||
("Do not install", len(g.avoid), "#a3231d"),
|
||||
("Cleared", len(g.go_behind), "#1f7a3d"),
|
||||
("New", run.new_since_last, "#2b5c8a"),
|
||||
("By hand", len(g.manual), "#4a5878"),
|
||||
]
|
||||
|
||||
|
||||
def summary_line(run: RunSummary, g: Groups) -> str:
|
||||
"""The one sentence someone reads on their phone."""
|
||||
# A live version mismatch outranks everything: it is already broken.
|
||||
if g.mismatched:
|
||||
names = ", ".join(f.item.name for f in g.mismatched[:3])
|
||||
return (
|
||||
f"{len(g.mismatched)} item(s) are running versions that do not "
|
||||
f"match gear they are required to match ({names}). That is a "
|
||||
f"fault now, not a future risk -- fix it before considering any "
|
||||
f"other update."
|
||||
)
|
||||
# Lead with this: if most of the fleet could not be checked, every other
|
||||
# number in the report is measuring a fraction of it.
|
||||
if g.needs_setup and len(g.needs_setup) > run.items_checked / 3:
|
||||
return (
|
||||
f"{len(g.needs_setup)} of {run.items_checked} tracked items could "
|
||||
f"not be checked this run because the version lookup is not "
|
||||
f"configured. They are in the inventory -- AVWatch just cannot "
|
||||
f"read their versions yet. Everything below covers only the "
|
||||
f"{run.items_checked - len(g.needs_setup)} items it could reach."
|
||||
)
|
||||
# Without baselines the rest of the numbers mean little either.
|
||||
if g.no_baseline and not g.actionable:
|
||||
return (
|
||||
f"AVWatch found current versions for {len(g.no_baseline)} item(s) "
|
||||
"but has no record of what you actually have installed, so it "
|
||||
"cannot tell you whether you are behind. Record the installed "
|
||||
"versions to make future briefings useful."
|
||||
)
|
||||
if g.avoid:
|
||||
worst = g.avoid[0]
|
||||
return (
|
||||
f"{len(g.avoid)} update(s) look actively dangerous this week — "
|
||||
f"most urgently {worst.item.name}. "
|
||||
f"{len(g.go_behind)} other update(s) are cleared to install."
|
||||
)
|
||||
if g.wait and not g.go_behind:
|
||||
return (
|
||||
f"Nothing is cleared to install this week. {len(g.wait)} update(s) "
|
||||
"are available but should soak longer before you touch them."
|
||||
)
|
||||
if g.go_behind:
|
||||
return (
|
||||
f"{len(g.go_behind)} update(s) are cleared to install, "
|
||||
f"{len(g.wait)} should wait, and nothing looks dangerous."
|
||||
)
|
||||
if run.updates_found == 0:
|
||||
return "No new updates across the fleet this week. Nothing to do."
|
||||
return f"{run.updates_found} update(s) available; see the detail below."
|
||||
|
||||
|
||||
def render_html(findings: list[Finding], run: RunSummary,
|
||||
out_path: Path | None = None) -> str:
|
||||
g = group(findings)
|
||||
html = _env().get_template("report.html.jinja").render(
|
||||
groups=g,
|
||||
run=run,
|
||||
generated_local=datetime.now().strftime("%A %d %B %Y, %H:%M"),
|
||||
engine_label=engine_label(findings),
|
||||
)
|
||||
if out_path:
|
||||
out_path = Path(out_path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(html, encoding="utf-8")
|
||||
return html
|
||||
|
||||
|
||||
def render_email(findings: list[Finding], run: RunSummary,
|
||||
report_path: Path | None = None) -> str:
|
||||
g = group(findings)
|
||||
return _env().get_template("email.html.jinja").render(
|
||||
groups=g,
|
||||
run=run,
|
||||
tiles=_tiles(run, g),
|
||||
summary_line=summary_line(run, g),
|
||||
generated_local=datetime.now().strftime("%A %d %B %Y, %H:%M"),
|
||||
engine_label=engine_label(findings),
|
||||
report_path=str(report_path) if report_path else None,
|
||||
hostname=socket.gethostname(),
|
||||
)
|
||||
|
||||
|
||||
def render_text(findings: list[Finding], run: RunSummary) -> str:
|
||||
"""Plain-text alternative part, and what ``avwatch report --text`` prints."""
|
||||
g = group(findings)
|
||||
lines = [
|
||||
"AVWATCH BRIEFING",
|
||||
datetime.now().strftime("%A %d %B %Y, %H:%M"),
|
||||
"=" * 62,
|
||||
"",
|
||||
summary_line(run, g),
|
||||
"",
|
||||
f"Tracked {run.items_checked} | behind {run.updates_found} | "
|
||||
f"new {run.new_since_last} | errors {run.errors}",
|
||||
"",
|
||||
]
|
||||
|
||||
if g.mismatched:
|
||||
title = f"VERSION MISMATCH -- BROKEN NOW ({len(g.mismatched)})"
|
||||
lines += [title, "=" * len(title)]
|
||||
for f in g.mismatched:
|
||||
lines.append(f"* {f.item.name}: running {f.item.installed}")
|
||||
for c in f.coupling:
|
||||
if c.severity == "blocking":
|
||||
lines.append(f" {c.message}")
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
|
||||
for title, bucket in (
|
||||
("DO NOT INSTALL", g.avoid),
|
||||
("HOLD OFF FOR NOW", g.wait),
|
||||
("CLEARED TO UPDATE", g.go_behind),
|
||||
):
|
||||
if not bucket:
|
||||
continue
|
||||
lines += [title, "-" * len(title)]
|
||||
for f in bucket:
|
||||
v = f.verdict
|
||||
flag = " [NEW]" if f.is_new_since_last_run else ""
|
||||
lines.append(f"* {f.item.name} ({f.item.vendor}){flag}")
|
||||
lines.append(
|
||||
f" {f.item.installed or 'unknown'} -> {f.check.latest}"
|
||||
+ (f" ({f.bump}, {f.check.age_days:.0f}d old)"
|
||||
if f.check.age_days is not None else "")
|
||||
)
|
||||
if v:
|
||||
if v.headline:
|
||||
lines.append(f" {v.headline}")
|
||||
for r in v.reasons[:4]:
|
||||
lines.append(f" - {r}")
|
||||
lines.append(
|
||||
f" risk {v.risk_score:.0f}/100, rule-based "
|
||||
f"{v.heuristic_score:.0f}/100 ({v.heuristic_recommendation})"
|
||||
)
|
||||
for e in f.evidence[:3]:
|
||||
lines.append(f" [{e.severity}] {e.title[:70]}")
|
||||
if e.url:
|
||||
lines.append(f" {e.url}")
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
|
||||
if g.no_baseline:
|
||||
title = "NO INSTALLED VERSION ON RECORD"
|
||||
lines += [title, "-" * len(title),
|
||||
"Latest version was found, but AVWatch does not know what you",
|
||||
"are running, so it cannot say whether you are behind.",
|
||||
"Record each one to fix this permanently:", ""]
|
||||
for f in g.no_baseline:
|
||||
rel = (f" (released {f.check.released.date().isoformat()})"
|
||||
if f.check.released else "")
|
||||
lines.append(f"* {f.item.name}: latest is {f.check.latest}{rel}")
|
||||
lines.append(f" python -m avwatch set-installed {f.item.id} <version>")
|
||||
lines.append("")
|
||||
|
||||
if g.needs_setup:
|
||||
title = f"COULD NOT BE CHECKED ({len(g.needs_setup)})"
|
||||
reasons: dict[str, list[str]] = {}
|
||||
for f in g.needs_setup:
|
||||
reasons.setdefault(f.check.detail or "unknown reason", []).append(
|
||||
f.item.name)
|
||||
lines += [title, "-" * len(title),
|
||||
"These ARE in the inventory. AVWatch could not read their",
|
||||
"current versions this run, for the reasons below.", ""]
|
||||
for reason, names in sorted(reasons.items(), key=lambda kv: -len(kv[1])):
|
||||
lines.append(f" {reason}")
|
||||
lines.append(f" affects {len(names)} item(s): "
|
||||
f"{', '.join(sorted(names)[:6])}"
|
||||
+ (f" and {len(names) - 6} more"
|
||||
if len(names) > 6 else ""))
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
|
||||
if g.frozen:
|
||||
title = "FROZEN BY POLICY (no action wanted)"
|
||||
lines += [title, "-" * len(title)]
|
||||
for f in g.frozen:
|
||||
newer = (f" -- {f.check.latest} is available"
|
||||
if f.comparison == "behind" else "")
|
||||
lines.append(f"* {f.item.name}: held at "
|
||||
f"{f.item.installed or 'current version'}{newer}")
|
||||
lines.append("")
|
||||
|
||||
if g.manual:
|
||||
lines += ["CHECK BY HAND", "-" * 13]
|
||||
for f in g.manual:
|
||||
lines.append(f"* {f.item.name} ({f.item.vendor}) - {f.item.check_url or 'no link set'}")
|
||||
if f.check.detail:
|
||||
lines.append(f" {f.check.detail}")
|
||||
lines.append("")
|
||||
|
||||
if g.errors:
|
||||
lines += ["SOURCE ERRORS", "-" * 13]
|
||||
for f in g.errors:
|
||||
lines.append(f"* {f.item.name}: {f.check.detail}")
|
||||
lines.append("")
|
||||
|
||||
lines += [
|
||||
"-" * 62,
|
||||
"Community posts are unverified writing by strangers. AVWatch ranks",
|
||||
"and summarises them; it does not vouch for them. Have a rollback",
|
||||
"plan before touching show-critical gear.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""SMTP delivery of the digest.
|
||||
|
||||
Works with Microsoft 365 and Google Workspace, both of which need an
|
||||
app-specific password (or a mailbox with SMTP AUTH enabled) -- your normal
|
||||
account password will be rejected if MFA is on. See README for the exact
|
||||
settings for each.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import EmailConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def validate(cfg: EmailConfig, dry_run: bool = False) -> list[str]:
|
||||
"""Return a list of configuration problems, empty if it looks sendable.
|
||||
|
||||
``dry_run`` relaxes the credential check: a dry run never opens a
|
||||
connection, so demanding a mail password before it will even build the
|
||||
report is pure obstruction -- and it hides the report the person was
|
||||
actually trying to look at.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
if not cfg.enabled:
|
||||
problems.append("email.enabled is false in config.toml")
|
||||
if not cfg.smtp_host:
|
||||
problems.append("email.smtp_host is not set")
|
||||
if not cfg.from_addr:
|
||||
problems.append("email.from_addr is not set")
|
||||
if not cfg.to:
|
||||
problems.append("email.to has no recipients")
|
||||
if cfg.username and not cfg.password and not dry_run:
|
||||
problems.append(
|
||||
"email.username is set but no password found -- set the "
|
||||
"AVWATCH_SMTP_PASSWORD environment variable"
|
||||
)
|
||||
if cfg.use_ssl and cfg.use_starttls:
|
||||
problems.append("use_ssl and use_starttls are mutually exclusive")
|
||||
return problems
|
||||
|
||||
|
||||
def build_message(
|
||||
cfg: EmailConfig,
|
||||
subject: str,
|
||||
html_body: str,
|
||||
text_body: str,
|
||||
attachment: Path | None = None,
|
||||
) -> EmailMessage:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"{cfg.subject_prefix} {subject}".strip()
|
||||
msg["From"] = formataddr((cfg.from_name, cfg.from_addr))
|
||||
msg["To"] = ", ".join(cfg.to)
|
||||
if cfg.cc:
|
||||
msg["Cc"] = ", ".join(cfg.cc)
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg["Message-ID"] = make_msgid(domain=cfg.from_addr.split("@")[-1] or None)
|
||||
# Digests are informational; keep them out of vacation-responder loops.
|
||||
msg["Auto-Submitted"] = "auto-generated"
|
||||
|
||||
msg.set_content(text_body)
|
||||
msg.add_alternative(html_body, subtype="html")
|
||||
|
||||
if attachment and cfg.attach_html:
|
||||
path = Path(attachment)
|
||||
if path.exists():
|
||||
msg.add_attachment(
|
||||
path.read_bytes(),
|
||||
maintype="text",
|
||||
subtype="html",
|
||||
filename=path.name,
|
||||
)
|
||||
else:
|
||||
log.warning("attachment not found, sending without it: %s", path)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def send(
|
||||
cfg: EmailConfig,
|
||||
subject: str,
|
||||
html_body: str,
|
||||
text_body: str,
|
||||
attachment: Path | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> str:
|
||||
"""Send the digest. Returns a human-readable result line."""
|
||||
problems = validate(cfg, dry_run=dry_run)
|
||||
if problems:
|
||||
raise EmailError("email not configured: " + "; ".join(problems))
|
||||
|
||||
msg = build_message(cfg, subject, html_body, text_body, attachment)
|
||||
recipients = list(cfg.to) + list(cfg.cc)
|
||||
|
||||
if dry_run:
|
||||
return (
|
||||
f"[dry run] would send {msg['Subject']!r} to "
|
||||
f"{', '.join(recipients)} via {cfg.smtp_host}:{cfg.smtp_port}"
|
||||
)
|
||||
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
if cfg.use_ssl:
|
||||
with smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port,
|
||||
context=context, timeout=60) as smtp:
|
||||
if cfg.username:
|
||||
smtp.login(cfg.username, cfg.password)
|
||||
smtp.send_message(msg, to_addrs=recipients)
|
||||
else:
|
||||
with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=60) as smtp:
|
||||
smtp.ehlo()
|
||||
if cfg.use_starttls:
|
||||
smtp.starttls(context=context)
|
||||
smtp.ehlo()
|
||||
if cfg.username:
|
||||
smtp.login(cfg.username, cfg.password)
|
||||
smtp.send_message(msg, to_addrs=recipients)
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
raise EmailError(
|
||||
f"SMTP rejected the login ({exc.smtp_code}). With Microsoft 365 or "
|
||||
"Google Workspace you need an app password, not the account "
|
||||
"password, and SMTP AUTH must be enabled for the mailbox."
|
||||
) from exc
|
||||
except smtplib.SMTPException as exc:
|
||||
raise EmailError(f"SMTP error: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise EmailError(
|
||||
f"could not reach {cfg.smtp_host}:{cfg.smtp_port}: {exc}"
|
||||
) from exc
|
||||
|
||||
return f"sent {msg['Subject']!r} to {', '.join(recipients)}"
|
||||
Reference in New Issue
Block a user