#!/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. # # Abgrenzung (Frage sorb 2026-08-01): REPOS unten ist eine HANDGEPFLEGTE Liste - # de facto ein Mini-SBOM auf Repo-Granularitaet, ohne Versions-/Dependency-Wissen. # Bei Stack-Aenderungen (neue Komponente, Fork-Wechsel) muss sie mitgezogen werden. # Trivy (gitops#31/#47) braucht dagegen keine Pflege: es leitet sein SBOM selbst # aus den Images ab. Beide ergaenzen sich, ersetzen sich nicht. 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"] # Eigener CVE-/Release-Raum (Entscheidung sorb 2026-08-01, gitops#47); # Fallback auf den Alerts-Raum, solange der Bot dort noch nicht eingeladen ist. ROOM_ID = os.environ.get("MATRIX_RELEASE_ROOM_ID") or 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)