diff --git a/monitoring/alertmanager/release-watch.py b/monitoring/alertmanager/release-watch.py new file mode 100644 index 0000000..18a832d --- /dev/null +++ b/monitoring/alertmanager/release-watch.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# Release-/Advisory-Watch fuer den Element-Stack (gitops#22). Gleiche Machart wie +# matrix-alerts.py: purer Stdlib-Daemon, Bot-Token aus der Umgebung, Nachricht per +# Client-Server-API in den Alerts-Raum. +# +# Quelle sind die oeffentlichen GitHub-Release-Atom-Feeds (kein API-Token noetig). +# Element veroeffentlicht Security-Fixes als Releases - der Feed ist damit der +# praktikable Advisory-Kanal; echte GHSA-Advisories haben keinen oeffentlichen Feed. +# Neue Eintraege werden einmalig als 📦-Notiz gemeldet; Security-verdaechtige +# Titel/Inhalte (CVE/security/vulnerab...) bekommen 🚨 und stehen vorn. +import json +import os +import re +import time +import urllib.parse +import urllib.request +import uuid +import xml.etree.ElementTree as ET + +HOMESERVER = os.environ["MATRIX_HOMESERVER"] +ROOM_ID = os.environ["MATRIX_ROOM_ID"] +TOKEN = os.environ["MATRIX_TOKEN"] +STATE_FILE = os.environ.get("RELEASE_WATCH_STATE_FILE", "/state/release-watch.json") +INTERVAL = int(os.environ.get("RELEASE_WATCH_INTERVAL", "21600")) # 6h + +REPOS = [ + "element-hq/synapse", + "element-hq/ess-helm", + "element-hq/element-web", + "element-hq/matrix-authentication-service", + "element-hq/element-call", +] +SECURITY_RE = re.compile(r"cve|security|vulnerab|advisory", re.IGNORECASE) +ATOM = "{http://www.w3.org/2005/Atom}" + +try: + with open(STATE_FILE) as f: + seen = json.load(f) # repo -> [entry ids] +except Exception: + seen = {} + + +def send_notice(text): + url = (f"{HOMESERVER}/_matrix/client/v3/rooms/{urllib.parse.quote(ROOM_ID)}" + f"/send/m.room.message/{uuid.uuid4()}") + req = urllib.request.Request(url, data=json.dumps({"msgtype": "m.notice", "body": text}).encode(), + method="PUT", headers={"Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json"}) + urllib.request.urlopen(req, timeout=10).read() + + +def check(repo): + feed = urllib.request.urlopen(f"https://github.com/{repo}/releases.atom", timeout=20).read() + root = ET.fromstring(feed) + entries = root.findall(f"{ATOM}entry") + known = set(seen.get(repo, [])) + first_run = repo not in seen + new = [] + for e in entries: + eid = e.findtext(f"{ATOM}id", "") + title = e.findtext(f"{ATOM}title", "?").strip() + link = "" + le = e.find(f"{ATOM}link") + if le is not None: + link = le.get("href", "") + content = e.findtext(f"{ATOM}content", "") or "" + if eid and eid not in known: + new.append((eid, title, link, bool(SECURITY_RE.search(title + " " + content[:2000])))) + # Erstlauf: nur Stand merken, nicht den Raum mit Historie fluten + seen[repo] = [e.findtext(f"{ATOM}id", "") for e in entries][:30] + if first_run: + return + for eid, title, link, is_sec in new: + icon = "\U0001F6A8" if is_sec else "\U0001F4E6" + kind = "Security-verdaechtiges Release" if is_sec else "Neues Release" + send_notice(f"{icon} {kind}: {repo} — {title}\n{link}") + + +while True: + for repo in REPOS: + try: + check(repo) + except Exception as exc: + print(f"{repo}: {exc}", flush=True) + try: + os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) + with open(STATE_FILE, "w") as f: + json.dump(seen, f) + except Exception as exc: + print(f"state: {exc}", flush=True) + time.sleep(INTERVAL) diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml index f59342c..72a4b50 100644 --- a/monitoring/docker-compose.yml +++ b/monitoring/docker-compose.yml @@ -58,6 +58,24 @@ services: networks: - traefik + # Release-/Advisory-Watch (gitops#22): meldet neue Releases der Element-Stack- + # Upstreams in den Alerts-Raum (🚨 bei Security-Verdacht). Gleicher Bot/Raum + # wie matrix-alerts, eigener State (Erstlauf merkt nur, flutet nicht). + release-watch: + image: python:3.13-slim + container_name: release-watch + restart: unless-stopped + environment: + - MATRIX_HOMESERVER=${MATRIX_ALERT_HOMESERVER} + - MATRIX_ROOM_ID=${MATRIX_ALERT_ROOM_ID} + - MATRIX_TOKEN=${MATRIX_ALERT_TOKEN} + volumes: + - ./alertmanager/release-watch.py:/app/release-watch.py:ro + - release_watch_data:/state + command: python3 /app/release-watch.py + networks: + - traefik + loki: image: grafana/loki:3.7.1 container_name: loki @@ -145,6 +163,7 @@ volumes: prometheus_data: alertmanager_data: matrix_alerts_data: + release_watch_data: grafana_data: loki_data: alloy_data: