diff --git a/.gitea/workflows/security-watch.yml b/.gitea/workflows/security-watch.yml new file mode 100644 index 00000000..3b39e106 --- /dev/null +++ b/.gitea/workflows/security-watch.yml @@ -0,0 +1,125 @@ +# security-watch — every night, read the security notices of everything Felhom ships, and mail the operator +# (admin@felhom.eu, never a household) what affects a pinned version (R-1303). The logic, its classes and its mails: +# scripts/security_watch.py; the list of pins: scripts/security_watch_inventory.py; why these sources: +# documentation/audits/security-watch-2026-10-26/SPIKE.md. +# +# THE MEMORY is documentation/security-watch/state.json on `main`, committed back by this job with its own Actions +# token. Why there and not elsewhere (2026-10-26): the two runners each have their OWN disk (act-runner-data, +# act-runner-2-data — a job lands on either), so a runner file would be two memories; Actions cache needs a node +# `uses:` step, which this python-and-git runner cannot run; anything on the DooPlex host is a host change. A file in +# the repo is one memory, readable by a person, and its history says when each advisory was first mailed. +# If the push of the memory fails, the job fails and the alarm below mails — the next night may then mail an advisory +# twice, which is the safe direction. +# +# Manual run (`workflow_dispatch`, mode): +# run a normal night, now +# seed record every advisory open now as known and mail nothing new (the first run; prints what it WOULD mail) +# dry no mail, no memory change — read the result table in the log +# test-mail send ONE clearly marked test advisory to the operator; touches nothing else +name: security-watch +on: + schedule: + - cron: "40 3 * * *" # 03:40 UTC: after the 03:20 gates run + workflow_dispatch: + inputs: + mode: + description: "run | seed | dry | test-mail" + default: "dry" + +jobs: + watch: + runs-on: felhom-gates + timeout-minutes: 20 + permissions: + contents: write + steps: + - name: Fetch main (latest, for the memory) and the two sibling repos + run: | + echo "WATCH_STARTED_AT=$(date +%s)" >> "$GITHUB_ENV" + base=http://gitea.gitea-system.svc.cluster.local:3000/admin + git init -q . && git remote add origin $base/felhom.eu.git + git fetch -q --depth 1 origin main && git checkout -q -B main FETCH_HEAD + echo "felhom.eu main at $(git rev-parse --short=12 HEAD)" + for r in felhom-controller app-catalog-felhom.eu; do + git init -q ../$r && git -C ../$r remote add origin $base/$r.git + git -C ../$r fetch -q --depth 1 origin main && git -C ../$r checkout -q FETCH_HEAD + echo "$r at $(git -C ../$r rev-parse --short=12 HEAD)" + done + + - name: Run the watch + env: + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + GH_ADVISORY_TOKEN: ${{ secrets.GH_ADVISORY_TOKEN }} + MODE: ${{ github.event.inputs.mode }} + run: | + set -u + mkdir -p documentation/security-watch + mode="${MODE:-run}" + [ "${GITHUB_EVENT_NAME:-}" = "schedule" ] && mode=run + echo "mode: $mode" + case "$mode" in + test-mail) python3 scripts/security_watch.py --test-mail; exit $? ;; + dry) extra="--no-mail" ; state=/tmp/state-dry.json + cp documentation/security-watch/state.json "$state" 2>/dev/null || true ;; + seed) extra="--seed" ; state=documentation/security-watch/state.json ;; + run) extra="" ; state=documentation/security-watch/state.json ;; + *) echo "unknown mode $mode"; exit 2 ;; + esac + python3 scripts/security_watch.py --state="$state" --catalog=../app-catalog-felhom.eu \ + --controller=../felhom-controller --json=/tmp/watch-results.json $extra + + - name: Commit the memory (only when it changed) + if: github.event.inputs.mode != 'dry' && github.event.inputs.mode != 'test-mail' + env: + PUSH_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -u + f=documentation/security-watch/state.json + if [ ! -f "$f" ]; then + echo "no memory file was written - nothing to commit"; exit 0 + fi + if git ls-files --error-unmatch "$f" >/dev/null 2>&1 && git diff --quiet -- "$f"; then + echo "memory unchanged - nothing to commit"; exit 0 + fi + cp "$f" /tmp/state.json + auth=$(python3 -c 'import base64,os;print(base64.b64encode(("x:"+os.environ["PUSH_TOKEN"]).encode()).decode())') + for attempt in 1 2 3; do + git fetch -q --depth 1 origin main && git checkout -q -B main FETCH_HEAD + mkdir -p "$(dirname "$f")" && cp /tmp/state.json "$f" + git add "$f" + git -c user.name="felhom security watch" -c user.email=monitoring@felhom.eu \ + commit -q -m "security-watch: memory of $(date -u +%F) (R-1303). Docs only." || { echo "nothing to commit"; exit 0; } + if git -c http.extraHeader="Authorization: Basic $auth" push -q origin HEAD:main; then + echo "memory pushed: $(git rev-parse --short=12 HEAD)"; exit 0 + fi + echo "push refused (attempt $attempt) - re-fetching main" + sleep 5 + done + echo "MEMORY NOT SAVED after 3 attempts"; exit 1 + + - name: Alarm on failure + if: failure() + env: + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + run: | + python3 - <<'PY' + import json, os, sys, time, urllib.request + key = os.environ.get("RESEND_API_KEY", "") + if not key: + sys.exit("ALARM FAILED: RESEND_API_KEY is empty") + run = os.environ.get("GITHUB_RUN_ID", "?") + link = "https://gitea.dooplex.hu/%s/actions/runs/%s" % (os.environ.get("GITHUB_REPOSITORY", "?"), run) + started = os.environ.get("WATCH_STARTED_AT", "") + elapsed = "%d s" % (int(time.time()) - int(started)) if started.isdigit() else "unknown" + print("ALARM-LINK " + link) + body = json.dumps({"from": "Felhom CI ", "to": ["admin@felhom.eu"], + "subject": "[felhom CI] security watch FAILED", + "text": ("The nightly security watch did not finish (R-1303).\n\nRun : %s\nElapsed : %s\n\n" + "Until it runs again, silence from the watch does NOT mean that nothing affects us.\n" + "The run log names the step: the watch itself, a mail it could not send, or its memory " + "it could not save.\n") % (link, elapsed)}).encode() + req = urllib.request.Request("https://api.resend.com/emails", data=body, method="POST", headers={ + "Authorization": "Bearer %s" % key, "Content-Type": "application/json", "User-Agent": "felhom-ci/1.0"}) + with urllib.request.urlopen(req, timeout=30) as r: + print("RESEND-ACCEPTED id=%s" % json.load(r)["id"]) + PY diff --git a/REUSE.md b/REUSE.md index 224e570e..4b2427b2 100644 --- a/REUSE.md +++ b/REUSE.md @@ -159,6 +159,12 @@ | `scheduleDaily` | hub/cmd/hub/main.go (~L449) | `(ctx, name, "HH:MM", fn, logger)` | Daily jobs in Europe/Budapest (prune etc.) | Blocking — run as goroutine. `parseHM` returns 0,0 (midnight) on bad input. | | `hu_grep.py` | scripts/hu_grep.py | `PATTERN --anchor ASCII [--negative TEXT] PATH…` | ANY search for Hungarian (accented) text in files — prints file:line hits, and refuses to report a zero unless an ASCII anchor hits and a negative control misses (R-364) | Reads bytes in Python, never via a shell; refuses a pattern that arrived transformed (octal escapes, U+FFFD). Exit 0 found · 1 tested zero · 2 refused | +### The list of everything we ship, with its upstream (R-1303, scripts/) +- `scripts/security_watch_inventory.py` — THE reader of every pin (catalog `image:` + `.felhom.yml` `upstream:`, + controller `infra.go` + `upstream.txt`, every Dockerfile `FROM` with its `# upstream:` comment) and of the source + grammar. The nightly `scripts/security_watch.py` and the catalog's and controller's `upstream` gates all import or call + it — never copy its parser. + ## 2. Canonical patterns (copy structure from THE named file) | Pattern | Canonical file | Key traits | diff --git a/hub/Dockerfile b/hub/Dockerfile index 42c98a3b..67aa5212 100644 --- a/hub/Dockerfile +++ b/hub/Dockerfile @@ -1,4 +1,5 @@ # R-972: a supported, patched Go and the current Alpine, pinned by version and digest (no moving tag). +# upstream: osv:Go/stdlib FROM golang:1.27.3-alpine3.24@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c AS builder WORKDIR /app @@ -19,6 +20,7 @@ RUN go mod tidy && \ -ldflags "-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" \ -o /felhom-hub ./cmd/hub/ +# upstream: gap:Alpine's package fixes (Samba among them) are in its secdb, which the watch does not read; BEHIND on the point release is the signal FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 RUN apk add --no-cache ca-certificates tzdata diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index 44f89cc4..3c7597d5 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,19 @@ +## 2026-10-26 (night) — the nightly security watch (R-1303) + +- **`security_watch.py`** (new): every night (CI `.gitea/workflows/security-watch.yml`, 03:40 UTC) it reads GitHub's + per-repo security advisories (with `GH_ADVISORY_TOKEN` when present; without it the apps' lists in a 2-night + rotation), the GitHub release feeds, the Cloudflare blog and cloudflared's `VULN-` notes, OSV for the Go builders and + the registry tags, and sorts every pin into ADVISORY / NOT CHECKED / GAP / FLOATING / BEHIND / CURRENT. Mails (operator + only): a new advisory that night (a reminder after 7 days), BEHIND weekly on Monday, a source down two nights. Its + memory is `documentation/security-watch/state.json`, committed back by the job. Spike: + `documentation/audits/security-watch-2026-10-26/SPIKE.md`. +- **`security_watch_inventory.py`** (new): the one reader of every pin and its upstream, and the `--gate=` the catalog + and the controller run (`upstream`). +- **`test_security_watch.py`** (new, 35 checks, no network): the classes with fake answers (today's cloudflared blog + item as a recorded answer), one mail per advisory, the 7-day reminder, a source down one night vs two, the weekly, the + seed, the inventory gate. Red-proved four ways (dedupe, streak, the 30-day window, a swallowed source error). +- `hub/Dockerfile`: a `# upstream:` comment above each `FROM`. + ## 2026-10-26 (evening) — CI: heavy suites by path, no second full gate run, a nightly run (R-1297 A); a decoy hole closed - **`script_tests_gate.py` `PATH_FILTERED`:** on a CI branch push with a known file list, `test_gate_decoys.py`, diff --git a/scripts/security_watch.py b/scripts/security_watch.py new file mode 100644 index 00000000..cf9740c8 --- /dev/null +++ b/scripts/security_watch.py @@ -0,0 +1,760 @@ +#!/usr/bin/env python3 +"""security_watch.py — the nightly watch for security notices on everything Felhom ships (R-1303). + +WHY. Cloudflare's eMudhra notice (R-1300) reached us by mail; our own check (check-infra-pins.py, monthly, by hand) would +have said only „a newer version exists", up to four weeks late. This job reads, every night, the places each program +publishes its security notices, and sorts every pin into exactly one class: + + ADVISORY a notice affects the version we pin (with the fixed version when the notice gives one) + NOT CHECKED a source this pin needs failed tonight — printed and counted, never a silent pass + GAP the pin has no machine-readable source (a stated `gap:` line); BEHIND is still shown + FLOATING the tag names no exact version (`redis:7-alpine`), so no range can be matched; BEHIND still shown + BEHIND a newer version exists in the same channel, and no notice + CURRENT none of the above + +WHAT IT READS (the spike that chose these: documentation/audits/security-watch-2026-10-26/SPIKE.md): + github:O/R GitHub's per-repo security advisories (API; GH_ADVISORY_TOKEN when present) + the release feed + (github.com/O/R/releases.atom — no API quota) for notes newer than our pin that name a CVE/GHSA/ + vulnerability/security fix + advisories:O/R the per-repo advisories of ANOTHER repo (Nextcloud) + osv:Go/stdlib OSV.dev for our Go builder version + feed:NAME a vendor feed (FEEDS below): an item in the last 30 days naming the program and a security word + vulnnotes:NAME a vendor release-notes file whose security lines carry a marker (VULNNOTES below) + registry tags Docker Hub / ghcr.io / lscr.io, for BEHIND (same channel = same tag shape, same major) +The list of pins: security_watch_inventory.py (ONE reader, shared with the catalog's and the controller's gates). + +MAILS (operator only, admin@felhom.eu, the CI's Resend path — no household ever gets one): + * ADVISORY: one mail that night with every NEW advisory (and a reminder for each still open after 7 days); + subject „[Felhom security] …"; each entry says what, our version, the fixed version, the + severity, the link, and „Next: the security-fix lane (B23)". + * BEHIND: one summary on Monday (or when the last one is older than 8 days), box programs first, then apps; GAP, + FLOATING and tonight's NOT CHECKED are listed in it too. + * NOT CHECKED: when the same source failed two nights in a row — once per streak. + +MEMORY: a JSON state file (`--state`), committed back to felhom.eu by the CI job (documentation/security-watch/ +state.json; the reasons in the workflow). A missing state file means: seed (record tonight's advisories as known, +mail nothing new) AND send one mail saying the memory was missing, with the open list — never a silent re-seed. + +USAGE + python3 scripts/security_watch.py --state= [--catalog=] [--controller=] [--seed] [--no-mail] + [--test-mail] [--today=YYYY-MM-DD] [--json=] +Exit: 0 the run finished (whatever it found) · 1 a mail could not be sent · 2 the list of pins is unreadable. +""" +import concurrent.futures +import datetime +import email.utils +import html +import json +import os +import re +import sys +import urllib.error +import urllib.request +import zlib + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import security_watch_inventory as inv # noqa: E402 + +UA = "felhom-security-watch/1.0 (+https://felhom.eu)" +MAIL_TO = "admin@felhom.eu" +MAIL_FROM = "Felhom security watch " +REMIND_DAYS = 7 +FEED_DAYS = 30 + +FEEDS = { + # The only automated way to Cloudflare's eMudhra notice (SPIKE.md, P1): the blog carries full text. + "cloudflare-blog": {"url": "https://blog.cloudflare.com/rss/", "keywords": ("cloudflared",)}, +} +VULNNOTES = { + # Cloudflare marks security fixes in cloudflared's notes with VULN- (2026.10.0, 2026.10.4). + "cloudflared": {"url": "https://raw.githubusercontent.com/cloudflare/cloudflared/master/RELEASE_NOTES", + "marker": "VULN-"}, +} +STRONG = re.compile(r"\bCVE-\d{4}-\d{3,}|\bGHSA(?:-[0-9a-z]{4}){3}\b|vulnerab|security (?:fix|issue|update|release|" + r"advisory|patch|vulnerability)|\[security\]", re.I) +FEED_WORDS = re.compile(r"vulnerab|compromis|\bCVE-\d{4}|security (?:update|fix|advisory|issue|notice)|exploit", re.I) + + +class SourceError(Exception): + pass + + +# ── network (the tests replace Net) ────────────────────────────────────────────────────────────────────────────────── +class Net: + def __init__(self, gh_token=""): + self.gh_token = gh_token + self.requests = {} + + def _count(self, url): + host = url.split("/")[2] + self.requests[host] = self.requests.get(host, 0) + 1 + + def get(self, url, headers=None, timeout=30): + self._count(url) + h = {"User-Agent": UA} + h.update(headers or {}) + try: + with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=timeout) as r: + return r.status, dict(r.headers), r.read() + except urllib.error.HTTPError as e: + return e.code, dict(e.headers or {}), e.read() if hasattr(e, "read") else b"" + except Exception as e: # noqa: BLE001 — every transport failure is a NOT CHECKED, named + raise SourceError("%s: %s" % (url.split("?")[0], e)) + + def post_json(self, url, body, timeout=30): + self._count(url) + req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST", + headers={"User-Agent": UA, "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.load(r) + except Exception as e: # noqa: BLE001 + raise SourceError("%s: %s" % (url, e)) + + +# ── versions ───────────────────────────────────────────────────────────────────────────────────────────────────────── +def version_of(tag): + m = re.match(r"^v?(\d+(?:\.\d+)*)", tag or "") + return tuple(int(x) for x in m.group(1).split(".")) if m else None + + +def vcmp(a, b): + n = max(len(a), len(b)) + a, b = a + (0,) * (n - len(a)), b + (0,) * (n - len(b)) + return (a > b) - (a < b) + + +PRERELEASE = re.compile(r"(?:alpha|beta|rc|dev|nightly|preview|pre|ea)[.\d-]*$|-(?:a|b)\d", re.I) + + +def is_floating(tag): + """A tag with fewer than three numbers (`redis:7.4-alpine`, `postgres:16-…`) takes the line's newest at install — + there is no exact version to match a notice's range against.""" + v = version_of(tag) + return v is None or len(v) < 3 + + +def same_channel(ours, other): + """Same major — except calendar versions (cloudflared 2026.x), whose first number is a year.""" + return ours[0] >= 1000 or ours[0] == other[0] + + +def range_affects(rng, ours, package=""): + """GHSA's free-text range ('>= v3.0.0, <= v3.7.15', '≤ 1.36.0', 'v3.2.2', 'server <= 3.0.3') -> True/False/None. + A clause of ONLY lower bounds with more than one of them is Nextcloud's per-branch shape ('>= 32.0.0, >= 33.0.0') + and reads as OR; every other clause is AND. A bare version means 'up to and including'.""" + if rng is None: + return None + pm = re.search(r"/v(\d+)$", package or "") + if pm and int(pm.group(1)) > 1 and int(pm.group(1)) != ours[0]: + return False + rng = rng.replace("≤", "<=").replace("≥", ">=").replace("=", "=") + any_clause = False + for clause in rng.split("||"): + cons = re.findall(r"(<=|>=|==|<|>|=)?\s*v?(\d+(?:\.\d+)+|\d+)(?![\d.])", clause) + cons = [(op, v) for op, v in cons if op or "." in v] + if not cons: + continue + any_clause = True + res = [] + for op, v in cons: + c = vcmp(ours, tuple(int(x) for x in v.split("."))) + res.append({"<=": c <= 0, ">=": c >= 0, "<": c < 0, ">": c > 0, "=": c == 0, "==": c == 0, "": c <= 0}[op]) + lower_only = len(cons) > 1 and all(op in (">=", ">") for op, _ in cons) + if (any(res) if lower_only else all(res)): + return True + return False if any_clause else None + + +def patched_for(text, ours): + """The fixed version that applies to OUR branch: the lowest named on our major; else the lowest named above us.""" + vs = [tuple(int(x) for x in v.split(".")) for v in re.findall(r"v?(\d+(?:\.\d+)+)", text or "")] + if not vs: + return None + same = [v for v in vs if v[0] == ours[0]] + if same: + return min(same) + if all(v[0] < ours[0] for v in vs) and ours[0] < 1000: + return max(vs) # every fix is on an older branch: ours is past all of them + return min(vs) + + +# A repo whose advisory list covers more than the program we run: only these package names count. +PACKAGE_ONLY = {"nextcloud/security-advisories": re.compile(r"^server$", re.I)} + + +def ghsa_affects(adv, ours, repo=""): + """-> (affected True/False/None, fixed text). None = the range is unreadable (listed, never dropped).""" + verdicts, fixed = [], [] + only = PACKAGE_ONLY.get(repo) + for vul in adv.get("vulnerabilities") or []: + pkg = (vul.get("package") or {}).get("name") or "" + if only and not only.search(pkg.strip()): + continue + r = range_affects(vul.get("vulnerable_version_range"), ours, pkg) + p = patched_for(vul.get("patched_versions"), ours) + if p is not None and vcmp(ours, p) >= 0 and r is not False: + r = False + elif r is None and p is not None: + r = vcmp(ours, p) < 0 + verdicts.append(r) + if r: + pv = (vul.get("patched_versions") or "").strip() + fixed.append(pv if pv and pv.lower() not in ("none", "tbd", "n/a") else "no fixed version named yet") + if any(v is True for v in verdicts): + return True, ", ".join(sorted(set(fixed))) + if not verdicts or all(v is False for v in verdicts): + return False if verdicts else (None if not only else False), "" if verdicts else "the notice names no version range" + return None, "the range is unreadable — check by hand" + + +# ── sources ────────────────────────────────────────────────────────────────────────────────────────────────────────── +class Sources: + def __init__(self, net, today, rotate): + self.net, self.today, self.rotate = net, today, rotate + self._adv, self._rel, self._feed, self._notes, self._osv, self._tags = {}, {}, {}, {}, {}, {} + self.rate_limited = False + + def gh_advisories(self, repo): + if repo in self._adv: + return self._adv[repo] + out = [] + if self.rate_limited: + raise SourceError("GitHub API rate limit reached earlier tonight") + url = "https://api.github.com/repos/%s/security-advisories?per_page=100&state=published" % repo + headers = {"Accept": "application/vnd.github+json"} + if self.net.gh_token: + headers["Authorization"] = "Bearer %s" % self.net.gh_token + for _ in range(5): + st, h, body = self.net.get(url, headers) + if st in (403, 429) and str(h.get("X-RateLimit-Remaining", h.get("x-ratelimit-remaining", ""))) == "0": + self.rate_limited = True + raise SourceError("GitHub API rate limit (%s)" % st) + if st != 200: + raise SourceError("GitHub advisories for %s: HTTP %s" % (repo, st)) + out += json.loads(body or b"[]") + m = re.search(r'<([^>]+)>;\s*rel="next"', h.get("Link", h.get("link", "")) or "") + if not m: + break + url = m.group(1) + self._adv[repo] = out + return out + + def release_feed(self, repo): + if repo in self._rel: + return self._rel[repo] + st, _h, body = self.net.get("https://github.com/%s/releases.atom" % repo) + if st != 200: + raise SourceError("release feed of %s: HTTP %s" % (repo, st)) + text = body.decode("utf-8", "replace") + entries = [] + for e in re.findall(r"(.*?)", text, re.S): + link = re.search(r']*href="([^"]+)"', e) + tag = link.group(1).rsplit("/", 1)[-1] if link else "" + title = html.unescape(re.sub(r"<[^>]+>", "", (re.search(r"(.*?)", e, re.S) or [""] * 2)[1])) + content = html.unescape(html.unescape((re.search(r"]*>(.*?)", e, re.S) or [""] * 2)[1])) + content = re.sub(r"<[^>]+>", " ", content) + entries.append({"tag": tag, "title": title.strip(), "content": content, + "link": link.group(1) if link else "https://github.com/%s/releases" % repo}) + self._rel[repo] = entries + return entries + + def feed(self, name): + if name in self._feed: + return self._feed[name] + st, _h, body = self.net.get(FEEDS[name]["url"]) + if st != 200: + raise SourceError("feed %s: HTTP %s" % (name, st)) + text = body.decode("utf-8", "replace") + items = [] + for it in re.findall(r"(.*?)", text, re.S): + def tag(t): + m = re.search(r"<%s[^>]*>(.*?)" % (t, t), it, re.S) + return html.unescape(re.sub(r"", "", m.group(1))) if m else "" + items.append({"title": tag("title").strip(), "link": tag("link").strip(), + "guid": tag("guid").strip() or tag("link").strip(), "date": tag("pubDate").strip(), + "text": tag("title") + " " + tag("description") + " " + tag("content:encoded")}) + self._feed[name] = items + return items + + def vulnnotes(self, name): + if name in self._notes: + return self._notes[name] + st, _h, body = self.net.get(VULNNOTES[name]["url"]) + if st != 200: + raise SourceError("release notes %s: HTTP %s" % (name, st)) + secs, cur = {}, None + for line in body.decode("utf-8", "replace").splitlines(): + if re.match(r"^\d+(?:\.\d+)+\s*$", line): + cur = line.strip() + secs[cur] = [] + elif cur and line.strip(): + secs[cur].append(line.strip()) + self._notes[name] = secs + return secs + + def osv(self, eco_name, version): + key = (eco_name, version) + if key in self._osv: + return self._osv[key] + eco, name = eco_name.split("/", 1) + d = self.net.post_json("https://api.osv.dev/v1/query", + {"package": {"ecosystem": eco, "name": name}, "version": version}) + self._osv[key] = d.get("vulns", []) + return self._osv[key] + + def _registry_tags(self, path): + st, _h, body = self.net.get("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull" % path) + if st != 200: + raise SourceError("Docker registry token for %s: HTTP %s" % (path, st)) + tok = json.loads(body).get("token", "") + st, _h, body = self.net.get("https://registry-1.docker.io/v2/%s/tags/list?n=10000" % path, + {"Authorization": "Bearer %s" % tok}) + if st != 200: + raise SourceError("Docker registry tags of %s: HTTP %s" % (path, st)) + return json.loads(body).get("tags") or [] + + def tags(self, image): + if image in self._tags: + return self._tags[image] + reg, path = registry_of(image) + names = [] + if reg == "docker.io": + url = "https://hub.docker.com/v2/repositories/%s/tags?page_size=100&ordering=last_updated" % path + for _ in range(3): + st, _h, body = self.net.get(url) + if st == 404 and not names: + names = self._registry_tags(path) # the web API misses some repos the registry serves (plant-it) + break + if st != 200: + raise SourceError("Docker Hub tags of %s: HTTP %s" % (path, st)) + d = json.loads(body) + names += [t["name"] for t in d.get("results", [])] + url = d.get("next") + if not url: + break + elif reg == "ghcr.io": + st, _h, body = self.net.get("https://ghcr.io/token?scope=repository:%s:pull" % path) + if st != 200: + raise SourceError("ghcr.io token for %s: HTTP %s" % (path, st)) + tok = json.loads(body).get("token", "") + st, _h, body = self.net.get("https://ghcr.io/v2/%s/tags/list?n=10000" % path, + {"Authorization": "Bearer %s" % tok}) + if st != 200: + raise SourceError("ghcr.io tags of %s: HTTP %s" % (path, st)) + names = json.loads(body).get("tags") or [] + else: + names = None # a registry the watch does not read (our own Gitea, GitLab): stated, not a failure + self._tags[image] = names + return names + + +def registry_of(image): + first = image.split("/", 1)[0] + if "." in first or ":" in first: + reg, path = first, image.split("/", 1)[1] if "/" in image else "" + else: + reg, path = "docker.io", image + if reg == "lscr.io": + reg = "docker.io" + if reg == "docker.io" and "/" not in path: + path = "library/" + path + return reg, path + + +# ── one pin ────────────────────────────────────────────────────────────────────────────────────────────────────────── +def newest_in_channel(tag, names): + ours = version_of(tag) + if not ours or not names: + return None, None + shape = re.compile("^" + re.sub(r"\d+", r"\\d+", re.escape(tag)) + "$") + best, newer_major = None, None + for n in names: + if not shape.match(n): + continue + v = version_of(n) + if not v or len(v) != len(ours): + continue + if same_channel(ours, v): + if vcmp(v, ours) > 0 and (best is None or vcmp(v, version_of(best)) > 0): + best = n + elif v[0] > ours[0] and (newer_major is None or vcmp(v, version_of(newer_major)) > 0): + newer_major = n + return best, newer_major + + +def check_item(it, src, today): + """-> the item with `class`, `advisories`, `not_checked`, `behind`, `notes` filled in.""" + ours = version_of(it["tag"]) + floating = is_floating(it["tag"]) + advs, nc, notes = [], [], [] + kinds = [k for k, _ in it.get("sources") or []] + for kind, arg in it.get("sources") or []: + try: + if kind in ("github", "advisories"): + if src.rotate and it["group"] == "app" and (zlib.crc32(arg.encode()) + today.toordinal()) % 2: + notes.append("advisories of %s: read tomorrow (no-key rotation)" % arg) + elif floating: + notes.append("advisories of %s: not matched (floating tag)" % arg) + else: + for a in src.gh_advisories(arg): + if a.get("withdrawn_at"): + continue + hit, fixed = ghsa_affects(a, ours, arg) + if hit is False: + continue + advs.append({"id": a.get("ghsa_id") or a.get("cve_id") or "?", "source": "GitHub advisory", + "severity": a.get("severity") or "", "summary": a.get("summary") or "", + "fixed": fixed, "link": a.get("html_url") or "", + "unsure": hit is None, "published": (a.get("published_at") or "")[:10]}) + if kind == "github" and not floating: + for e in src.release_feed(arg): + v = version_of(e["tag"]) + if not v or PRERELEASE.search(e["tag"]) or vcmp(v, ours) <= 0 or not same_channel(ours, v): + continue + m = STRONG.search(e["content"]) or STRONG.search(e["title"]) + if m: + line = next((ln.strip() for ln in re.split(r"[\n.]", e["content"]) if STRONG.search(ln)), m.group(0)) + advs.append({"id": "release:%s@%s" % (arg, e["tag"]), "source": "release notes", + "severity": "", "summary": line[:200], "fixed": e["tag"], "link": e["link"], + "unsure": False, "published": ""}) + elif kind == "osv" and not floating: + ver = ".".join(str(x) for x in ours) + for vuln in src.osv(arg, ver): + fx = [] + for a in vuln.get("affected", []): + for r in a.get("ranges", []): + fx += [e["fixed"] for e in r.get("events", []) if "fixed" in e] + advs.append({"id": vuln.get("id", "?"), "source": "OSV (%s)" % arg, "severity": "", + "summary": vuln.get("summary") or vuln.get("details", "")[:160], + "fixed": ", ".join(sorted(set(fx))) or "?", "unsure": False, + "link": "https://osv.dev/vulnerability/%s" % vuln.get("id", ""), "published": ""}) + elif kind == "feed": + cut = today - datetime.timedelta(days=FEED_DAYS) + for item in src.feed(arg): + try: + d = email.utils.parsedate_to_datetime(item["date"]).date() + except Exception: # noqa: BLE001 — an undated item is judged, never dropped + d = today + low = item["text"].lower() + if d < cut or not any(k in low for k in FEEDS[arg]["keywords"]) or not FEED_WORDS.search(item["text"]): + continue + advs.append({"id": "feed:%s:%s" % (arg, item["guid"]), "source": "vendor notice (%s)" % arg, + "severity": "", "summary": item["title"], "fixed": "read the notice", "unsure": True, + "link": item["link"], "published": str(d), "no_reminder": True}) + elif kind == "vulnnotes" and not floating: + for ver, lines in src.vulnnotes(arg).items(): + v = version_of(ver) + if v and vcmp(v, ours) > 0: + for ln in lines: + if VULNNOTES[arg]["marker"] in ln: + vid = re.search(r"%s\S+" % re.escape(VULNNOTES[arg]["marker"]), ln) + advs.append({"id": "vulnnotes:%s@%s:%s" % (arg, ver, vid.group(0).rstrip(":") if vid else ""), + "source": "release notes (%s)" % arg, "severity": "", "summary": ln[:200], + "fixed": ver, "unsure": False, "published": "", + "link": VULNNOTES[arg]["url"]}) + except SourceError as e: + nc.append({"source": source_name(kind, arg), "why": str(e)}) + behind, newer_major = None, None + if it.get("no_registry"): + gh = next((a for k, a in it.get("sources") or [] if k == "github"), None) + try: + names = [e["tag"].lstrip("v") for e in src.release_feed(gh)] if gh else None + if names is None: + notes.append("no registry and no release feed to compare with") + else: + behind, newer_major = newest_in_channel(it["tag"].lstrip("v"), names) + except SourceError as e: + nc.append({"source": "github", "why": str(e)}) + elif "self" not in kinds: + try: + names = src.tags(it["image"]) + if names is None: + notes.append("registry not read (%s)" % registry_of(it["image"])[0]) + else: + behind, newer_major = newest_in_channel(it["tag"], names) + except SourceError as e: + nc.append({"source": "registry:%s" % registry_of(it["image"])[0], "why": str(e)}) + if newer_major: + notes.append("a newer major exists: %s" % newer_major) + it.update({"advisories": advs, "not_checked": nc, "behind": behind, "notes": notes}) + if it.get("problems"): + nc.append({"source": "inventory", "why": "; ".join(it["problems"])}) + if advs: + it["class"] = "ADVISORY" + elif nc: + it["class"] = "NOT CHECKED" + elif "gap" in kinds and not [k for k in kinds if k != "gap"]: + it["class"] = "GAP" + elif "self" in kinds and len(kinds) == 1: + it["class"] = "SELF" + elif floating: + it["class"] = "FLOATING" + elif behind: + it["class"] = "BEHIND" + else: + it["class"] = "CURRENT" + return it + + +def source_name(kind, arg): + return {"github": "github", "advisories": "github", "osv": "osv", "feed": "feed:" + arg, + "vulnnotes": "vulnnotes:" + arg}.get(kind, kind) + + +# ── memory and mails (pure: the tests drive this directly) ────────────────────────────────────────────────────────── +def adv_key(it, a): + return "%s|%s" % (it["image"], a["id"]) + + +def label(it): + return "%s %s (%s)" % (it["image"], it["tag"], it["owner"]) + + +def plan(items, state, today, seed=False): + """-> (mails, new_state, would_have_mailed). A mail is {"kind","subject","text"}.""" + st = json.loads(json.dumps(state or {})) + st.setdefault("version", 1) + known = st.setdefault("advisories", {}) + fails = st.setdefault("failures", {}) + iso = today.isoformat() + mails, would = [], [] + open_now, checked_images = {}, set() + for it in items: + if not it.get("not_checked"): + checked_images.add(it["image"]) + for a in it.get("advisories", []): + open_now[adv_key(it, a)] = (it, a) + new, remind = [], [] + for k, (it, a) in sorted(open_now.items()): + if k not in known: + new.append((it, a)) + known[k] = {"first": iso, "last_mailed": iso, "program": label(it), "summary": a["summary"][:160]} + elif not a.get("no_reminder") and (today - datetime.date.fromisoformat(known[k]["last_mailed"])).days >= REMIND_DAYS: + remind.append((it, a)) + known[k]["last_mailed"] = iso + for k in list(known): + if k not in open_now and k.split("|", 1)[0] in checked_images: + del known[k] # closed: its pin was checked tonight and the notice no longer applies + if new or remind: + m = advisory_mail(new, remind, today) + if seed: + would.append(m) + else: + mails.append(m) + failed_now = {} + for it in items: + for f in it.get("not_checked", []): + failed_now.setdefault(f["source"], []).append("%s — %s" % (label(it), f["why"])) + streak_mail = [] + for s in sorted(set(fails) | set(failed_now)): + rec = fails.setdefault(s, {"streak": 0, "mailed": False}) + if s in failed_now: + rec["streak"] += 1 + if rec["streak"] >= 2 and not rec["mailed"]: + streak_mail.append((s, rec["streak"], failed_now[s])) + rec["mailed"] = True + else: + del fails[s] + if streak_mail and not seed: + mails.append(not_checked_mail(streak_mail, today)) + last_weekly = st.get("weekly_last") + due = today.weekday() == 0 and last_weekly != iso + if last_weekly and (today - datetime.date.fromisoformat(last_weekly)).days >= 8: + due = True + if seed: + st["weekly_last"] = iso + st["seeded_at"] = iso + elif due: + mails.append(weekly_mail(items, today)) + st["weekly_last"] = iso + return mails, st, would + + +def _adv_lines(it, a, reminder=False): + return ["%s%s — %s" % ("REMINDER (still open): " if reminder else "", it["image"], a["id"]), + " What: %s" % (a["summary"] or "(no summary)"), + " Our version: %s (%s; %s)" % (it["tag"], it["owner"], it["where"]), + " Fixed in: %s" % (a["fixed"] or "?"), + " Severity: %s" % (a["severity"] or "not given"), + " Source: %s%s" % (a["source"], " — the range could not be read; check by hand" if a.get("unsure") and + a["source"] == "GitHub advisory" else ""), + " Link: %s" % (a["link"] or "-"), + " Next: the security-fix lane (B23) — capability map B23; for a box program, a controller release", + ""] + + +def advisory_mail(new, remind, today): + allp = new + remind + box = [it for it, _ in allp if it["group"] != "app"] + names = [] + for it, _ in sorted(allp, key=lambda p: (p[0]["group"] == "app", p[0]["image"])): + n = it["image"].rsplit("/", 1)[-1] + if n not in names: + names.append(n) + subject = "[Felhom security] %s — %d new advisor%s%s" % ( + ", ".join(names[:4]) + (" …" if len(names) > 4 else ""), len(new), "y" if len(new) == 1 else "ies", + ", %d reminder%s" % (len(remind), "" if len(remind) == 1 else "s") if remind else "") + lines = ["The nightly security watch (felhom.eu R-1303) found a notice that affects a version we ship.", "", + "Box programs first%s." % ("" if box else " (none tonight)"), ""] + for it, a in sorted(new, key=lambda p: (p[0]["group"] == "app", p[0]["image"])): + lines += _adv_lines(it, a) + for it, a in sorted(remind, key=lambda p: (p[0]["group"] == "app", p[0]["image"])): + lines += _adv_lines(it, a, reminder=True) + lines += ["A notice is mailed once, and again every %d days while it stays open. Night of %s." % (REMIND_DAYS, today)] + return {"kind": "advisory", "subject": subject, "text": "\n".join(lines)} + + +def not_checked_mail(streaks, today): + lines = ["The security watch could not check these sources for two nights or more. Silence from the watch does", + 'not mean "safe" while this lasts.', ""] + for s, n, what in streaks: + lines.append("%s — %d nights in a row:" % (s, n)) + lines += [" " + w for w in what[:20]] + ([" … %d more" % (len(what) - 20)] if len(what) > 20 else []) + [""] + lines.append("Night of %s. The run log names every failure." % today) + return {"kind": "not_checked", "subject": "[Felhom security] the watch could not check %s" % ", ".join(s for s, _, _ in streaks), + "text": "\n".join(lines)} + + +def weekly_mail(items, today): + def block(title, rows): + return ([title] + [" " + r for r in rows] + [""]) if rows else [] + lines = ["The weekly summary of the security watch (felhom.eu R-1303): newer versions with no security notice.", ""] + for grp, name in (("box", "Box programs and builds"), ("app", "Catalog apps")): + sel = [it for it in items if (it["group"] == "app") == (grp == "app")] + lines += block(name + " — BEHIND:", ["%s %s -> %s (%s)" % (it["image"], it["tag"], it["behind"], it["owner"]) + for it in sel if it.get("behind")]) + lines += block("Open advisories (mailed already):", sorted({"%s %s — %s" % (it["image"], it["tag"], a["id"]) + for it in items for a in it.get("advisories", [])})) + lines += block("No machine-readable source (GAP) — not watched for notices:", + sorted({"%s — %s" % (it["image"], next((v for k, v in it.get("sources", []) if k == "gap"), "")) + for it in items if it.get("class") == "GAP"})) + lines += block("Floating tags (no exact version to match a notice against):", + sorted({"%s:%s (%s)" % (it["image"], it["tag"], it["owner"]) for it in items if it.get("class") == "FLOATING"})) + lines += block("NOT CHECKED tonight:", ["%s — %s" % (label(it), "; ".join(f["why"] for f in it["not_checked"])) + for it in items if it.get("not_checked")]) + counts = {} + for it in items: + counts[it.get("class", "?")] = counts.get(it.get("class", "?"), 0) + 1 + lines.append("Counts: " + ", ".join("%s %d" % kv for kv in sorted(counts.items()))) + return {"kind": "weekly", "subject": "[Felhom security] weekly: %d behind, %d advisories open" % ( + sum(1 for it in items if it.get("behind")), sum(len(it.get("advisories", [])) for it in items)), + "text": "\n".join(lines)} + + +def send(mail, key, test=False): + body = json.dumps({"from": MAIL_FROM, "to": [MAIL_TO], "subject": mail["subject"], "text": mail["text"]}).encode() + req = urllib.request.Request("https://api.resend.com/emails", data=body, method="POST", headers={ + "Authorization": "Bearer %s" % key, "Content-Type": "application/json", + # Cloudflare in front of api.resend.com refuses urllib's default agent (403 error 1010, measured 2026-08-02). + "User-Agent": "felhom-ci/1.0"}) + with urllib.request.urlopen(req, timeout=30) as r: + return json.load(r)["id"] + + +# ── the run ────────────────────────────────────────────────────────────────────────────────────────────────────────── +def run(items, net, today, rotate): + src = Sources(net, today, rotate) + # Box programs first, one by one (they share sources with nothing); apps in a small pool. + box = [it for it in items if it["group"] != "app"] + apps = [it for it in items if it["group"] == "app"] + for it in box: + check_item(it, src, today) + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as ex: + list(ex.map(lambda it: check_item(it, src, today), apps)) + return items + + +def table(items): + order = {"ADVISORY": 0, "NOT CHECKED": 1, "BEHIND": 2, "GAP": 3, "FLOATING": 4, "SELF": 5, "CURRENT": 6} + out = [] + for it in sorted(items, key=lambda i: (order.get(i["class"], 9), i["group"] == "app", i["image"])): + extra = "" + if it["class"] == "ADVISORY": + extra = "; ".join("%s (fixed %s)" % (a["id"], a["fixed"]) for a in it["advisories"][:4]) + if len(it["advisories"]) > 4: + extra += "; … %d more" % (len(it["advisories"]) - 4) + elif it["class"] == "NOT CHECKED": + extra = "; ".join(f["why"] for f in it["not_checked"])[:200] + elif it.get("behind"): + extra = "newest " + it["behind"] + out.append("%-11s %-4s %-46s %-22s %s" % (it["class"], it["group"][:4], it["image"][:46], it["tag"][:22], extra)) + return out + + +def main(argv): + opts = dict(a[2:].split("=", 1) if "=" in a else (a[2:], "1") for a in argv if a.startswith("--")) + today = datetime.date.fromisoformat(opts["today"]) if "today" in opts else datetime.datetime.utcnow().date() + key = os.environ.get("RESEND_API_KEY", "") + if "test-mail" in opts: + it = {"image": "TEST-program", "tag": "1.0.0", "owner": "a test entry", "where": "no file", "group": "box"} + a = {"id": "TEST-0000", "summary": "THIS IS A TEST — not a real advisory (R-1303 live proof)", "fixed": "1.0.1", + "severity": "none (test)", "source": "test", "link": "https://felhom.eu", "unsure": False} + m = advisory_mail([(it, a)], [], today) + m["subject"] = "[Felhom security] TEST — not a real advisory: " + m["subject"].split("] ", 1)[1] + if not key: + print("TEST MAIL NOT SENT: RESEND_API_KEY is empty") + return 1 + print("TEST-MAIL RESEND-ACCEPTED id=%s subject=%s" % (send(m, key), m["subject"])) + return 0 + try: + items, problems = inv.full_inventory(catalog=opts.get("catalog"), controller=opts.get("controller")) + except inv.InventoryError as e: + print("THE LIST OF PINS IS UNREADABLE: %s" % e) + return 2 + token = os.environ.get("GH_ADVISORY_TOKEN", "") + net = Net(token) + print("security watch %s: %d pins (%d box/build, %d app); GitHub key %s" % ( + today, len(items), sum(1 for i in items if i["group"] != "app"), sum(1 for i in items if i["group"] == "app"), + "PRESENT — every repo's advisories tonight" if token else "ABSENT — apps' advisories in a 2-night rotation")) + run(items, net, today, rotate=not token) + for line in table(items): + print(line) + counts = {} + for it in items: + counts[it["class"]] = counts.get(it["class"], 0) + 1 + print("COUNTS " + " ".join("%s=%d" % (k.replace(" ", "_"), v) for k, v in sorted(counts.items()))) + print("REQUESTS " + " ".join("%s=%d" % kv for kv in sorted(net.requests.items()))) + state_path = opts.get("state") + state, missing = {}, True + if state_path and os.path.isfile(state_path): + state, missing = json.load(open(state_path, encoding="utf-8")), False + seed = "seed" in opts or missing + mails, new_state, would = plan(items, state, today, seed=seed) + if missing and "seed" not in opts: + lines = ["The watch's memory (%s) was MISSING tonight, so it re-seeded: every advisory open tonight is now" % state_path, + "recorded as known, and nothing was mailed as new. Read the list below — it is what is open.", ""] + for w in would: + lines += [w["subject"], w["text"], ""] + mails.append({"kind": "state_missing", "subject": "[Felhom security] the watch's memory was missing — re-seeded", + "text": "\n".join(lines) or "(no open advisory)"}) + for w in would: + print("WOULD HAVE MAILED (seed): %s" % w["subject"]) + print(w["text"]) + if "json" in opts: + json.dump(items, open(opts["json"], "w", encoding="utf-8"), indent=1, ensure_ascii=False, default=str) + rc = 0 + for m in mails: + if "no-mail" in opts: + print("MAIL (not sent, --no-mail): %s" % m["subject"]) + continue + if not key: + print("MAIL NOT SENT — RESEND_API_KEY is empty: %s" % m["subject"]) + rc = 1 + continue + try: + print("MAIL RESEND-ACCEPTED id=%s kind=%s subject=%s" % (send(m, key), m["kind"], m["subject"])) + except Exception as e: # noqa: BLE001 + print("MAIL FAILED (%s): %s" % (m["subject"], e)) + rc = 1 + if state_path and rc == 0: + with open(state_path + ".tmp", "w", encoding="utf-8") as f: + json.dump(new_state, f, indent=1, sort_keys=True, ensure_ascii=False) + f.write("\n") + os.replace(state_path + ".tmp", state_path) + print("STATE written: %s (%d known advisories)" % (state_path, len(new_state.get("advisories", {})))) + return rc + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/security_watch_inventory.py b/scripts/security_watch_inventory.py new file mode 100644 index 00000000..f54545f9 --- /dev/null +++ b/scripts/security_watch_inventory.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""security_watch_inventory.py — ONE list of every program Felhom ships, each with its pinned version and the place its +security notices are published (R-1303). Read by the nightly watch (security_watch.py) and by the catalog's and the +controller's push gates; never copied (the reuse_refs_check.py rule). + +WHERE THE FACTS LIVE (each kept by a person, beside the pin it describes): + * catalog templates//docker-compose.yml `image:` lines, and the `upstream:` block of the same template's + `.felhom.yml` — one line per image, keyed by the image without its tag. + * box controller/internal/infra/infra.go `*Image` consts, and controller/internal/infra/upstream.txt beside it. + * builds every `FROM` in controller/Dockerfile, controller/infra-images/samba/Dockerfile and felhom.eu + hub/Dockerfile, and `ARG RESTIC_VERSION`, each with a `# upstream: …` comment on the line ABOVE it. + +THE SOURCE GRAMMAR (space-separated, at least one; NEW-APP-CHECKLIST.md 1.10 points here): + github:OWNER/REPO the repo's published security advisories AND its release notes (release feed) + advisories:OWNER/REPO advisories published in ANOTHER repo (Nextcloud: nextcloud/security-advisories) + osv:ECOSYSTEM/NAME an OSV.dev package at our version (today only Go/stdlib, for the Go builders) + feed:NAME a vendor feed the watch knows by name (security_watch.FEEDS: cloudflare-blog) + vulnnotes:NAME a vendor's release-notes file whose security lines carry a marker (cloudflared: VULN-) + self our own build — its code is scanned by security_scan_gate.py, not watched here + gap: no machine-readable source exists; the reason is listed in every weekly mail + +A pin with NO line refuses the push (`--gate`). A gap is allowed — it is a stated hole, not a silent one. + +USAGE + python3 scripts/security_watch_inventory.py --list [--json] # the generated list (siblings beside this repo) + python3 scripts/security_watch_inventory.py --gate=catalog + python3 scripts/security_watch_inventory.py --gate=controller + python3 scripts/security_watch_inventory.py --gate=hub +Exit (gate): 0 every pin has an upstream · 1 convicted · 2 a file nobody can read. +Decoys: catalog `scripts/test_gate_decoys.py` and controller `controller/scripts/test_gate_decoys.py` (COVERS "upstream"). +stdlib only — the CI runners carry python3 and git and nothing else (no PyYAML). +""" +import json +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +FELHOM = os.path.dirname(HERE) +WORKSPACE = os.environ.get("FELHOM_WORKSPACE") or os.path.dirname(FELHOM) + +KINDS = ("github", "advisories", "osv", "feed", "vulnnotes", "self", "gap") +KNOWN_FEEDS = ("cloudflare-blog",) +KNOWN_VULNNOTES = ("cloudflared",) +REPO_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9._-]+$") +IMAGE_RX = re.compile(r"^\s*image:\s*[\"']?([^\"'\s#]+)") +FROM_RX = re.compile(r"^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)", re.I) +UPSTREAM_COMMENT_RX = re.compile(r"^\s*#\s*upstream:\s*(.+?)\s*$") + + +class InventoryError(Exception): + """A file nobody can read — the gate exits 2, the watch counts it as NOT CHECKED.""" + + +def parse_sources(text): + """'github:a/b gap:…' -> ([(kind, arg)], [problems]). A gap's reason runs to the end of the line.""" + text = (text or "").strip().strip('"').strip("'").strip() + out, problems = [], [] + if not text: + return out, ["no source"] + rest = text + while rest: + m = re.match(r"(\w+)(?::(\S*))?\s*", rest) + if not m: + problems.append("unreadable source %r" % rest) + break + kind, arg = m.group(1), m.group(2) or "" + if kind == "gap": + reason = rest[len("gap:"):].strip() if rest.startswith("gap:") else "" + if len(reason) < 10: + problems.append("gap without a reason (at least a sentence: why no source exists)") + out.append(("gap", reason)) + break + rest = rest[m.end():] + if kind not in KINDS: + problems.append("unknown source kind %r" % kind) + elif kind in ("github", "advisories") and not REPO_RX.match(arg): + problems.append("%s needs OWNER/REPO, got %r" % (kind, arg)) + elif kind == "osv" and not re.match(r"^[A-Za-z.:]+/\S+$", arg): + problems.append("osv needs ECOSYSTEM/NAME, got %r" % arg) + elif kind == "feed" and arg not in KNOWN_FEEDS: + problems.append("unknown feed %r (known: %s)" % (arg, ", ".join(KNOWN_FEEDS))) + elif kind == "vulnnotes" and arg not in KNOWN_VULNNOTES: + problems.append("unknown vulnnotes %r (known: %s)" % (arg, ", ".join(KNOWN_VULNNOTES))) + elif kind == "self" and arg: + problems.append("self takes no argument") + out.append((kind, arg)) + return out, problems + + +def split_ref(ref): + """'ghcr.io/a/b:v1.2@sha256:…' -> ('ghcr.io/a/b', 'v1.2'). A ref with no tag reads as 'latest'.""" + ref = ref.split("@", 1)[0] + head, _, last = ref.rpartition("/") + if ":" in last: + name, tag = last.split(":", 1) + return (head + "/" + name) if head else name, tag + return ref, "latest" + + +def _read(path): + try: + with open(path, encoding="utf-8") as f: + return f.read() + except OSError as e: + raise InventoryError("cannot read %s: %s" % (path, e)) + + +def felhom_upstream_block(text): + """The `upstream:` mapping of a .felhom.yml: {image: source text}. A line reader — only top-level `upstream:` and + its two-space-indented `key: value` lines count; a comment or another key's nested block never does.""" + out, inside = {}, False + for line in text.splitlines(): + if re.match(r"^upstream:\s*(#.*)?$", line): + inside = True + continue + if not inside: + continue + if not line.strip() or line.lstrip().startswith("#"): + continue + if not line.startswith(" "): + break # the next top-level key ends the block + m = re.match(r"^\s+([^\s:#][^\s#]*?):\s+(.+?)\s*$", line) + if m: + out[m.group(1)] = m.group(2) + return out + + +def catalog_items(root): + items = [] + tdir = os.path.join(root, "templates") + if not os.path.isdir(tdir): + raise InventoryError("no templates/ under %s" % root) + for app in sorted(os.listdir(tdir)): + compose = os.path.join(tdir, app, "docker-compose.yml") + if not os.path.isfile(compose): + continue + ups = felhom_upstream_block(_read(os.path.join(tdir, app, ".felhom.yml"))) \ + if os.path.isfile(os.path.join(tdir, app, ".felhom.yml")) else {} + seen = set() + for line in _read(compose).splitlines(): + m = IMAGE_RX.match(line) + if not m: + continue + image, tag = split_ref(m.group(1)) + if (image, tag) in seen: + continue + seen.add((image, tag)) + items.append({"group": "app", "owner": app, "image": image, "tag": tag, + "where": "templates/%s/.felhom.yml upstream: %s" % (app, image), + "sources_text": ups.get(image)}) + return items + + +def infra_table(path): + """upstream.txt beside infra.go: `ConstName source…` lines; # comments and blank lines ignored.""" + out = {} + for line in _read(path).splitlines(): + s = line.split("#", 1)[0].strip() if not line.lstrip().startswith("#") else "" + if not s: + continue + name, _, rest = s.partition(" ") + out[name] = rest.strip() + return out + + +def dockerfile_items(path, owner, group="build"): + """Every FROM (and ARG RESTIC_VERSION) with the `# upstream:` comment on the line above it.""" + items, prev = [], "" + stages = set() + for line in _read(path).splitlines(): + m = FROM_RX.match(line) + if m: + ref = m.group(1) + st = re.search(r"\s+AS\s+(\S+)\s*$", line, re.I) + if ref.lower() not in stages and ref != "scratch": + image, tag = split_ref(ref) + c = UPSTREAM_COMMENT_RX.match(prev) + items.append({"group": group, "owner": owner, "image": image, "tag": tag, + "where": "%s FROM %s" % (os.path.relpath(path, WORKSPACE), image), + "sources_text": c.group(1) if c else None}) + if st: + stages.add(st.group(1).lower()) + a = re.match(r"^\s*ARG\s+RESTIC_VERSION=(\S+)", line) + if a: + c = UPSTREAM_COMMENT_RX.match(prev) + items.append({"group": group, "owner": owner, "image": "restic", "tag": a.group(1), "no_registry": True, + "where": "%s ARG RESTIC_VERSION" % os.path.relpath(path, WORKSPACE), + "sources_text": c.group(1) if c else None}) + if line.strip(): + prev = line + return items + + +def controller_items(repo): + ctrl = os.path.join(repo, "controller") + src = _read(os.path.join(ctrl, "internal", "infra", "infra.go")) + table = infra_table(os.path.join(ctrl, "internal", "infra", "upstream.txt")) + items = [] + for const, ref in re.findall(r"^\s*(\w+Image)\s*=\s*\"([^\"]+)\"", src, re.M): + image, tag = split_ref(ref) + items.append({"group": "box", "owner": "controller infra", "image": image, "tag": tag, "const": const, + "where": "controller/internal/infra/upstream.txt %s" % const, + "sources_text": table.get(const)}) + if not items: + raise InventoryError("no *Image consts in infra.go — the reader is broken, not the pins") + items += dockerfile_items(os.path.join(ctrl, "Dockerfile"), "controller image") + samba = os.path.join(ctrl, "infra-images", "samba", "Dockerfile") + if os.path.isfile(samba): + items += dockerfile_items(samba, "felhom-samba image") + return items + + +def hub_items(felhom_root): + return dockerfile_items(os.path.join(felhom_root, "hub", "Dockerfile"), "hub image") + + +def judge(items): + """Attach parsed sources and problems; returns the problems list (empty = every pin has an upstream).""" + problems = [] + for it in items: + if it.get("sources_text") is None: + it["sources"], it["problems"] = [], ["no upstream line"] + else: + it["sources"], it["problems"] = parse_sources(it["sources_text"]) + for p in it["problems"]: + problems.append("%s (%s:%s): %s" % (it["where"], it["image"], it["tag"], p)) + return problems + + +def full_inventory(felhom_root=FELHOM, catalog=None, controller=None): + catalog = catalog or os.path.join(WORKSPACE, "app-catalog-felhom.eu") + controller = controller or os.path.join(WORKSPACE, "felhom-controller") + items = controller_items(controller) + hub_items(felhom_root) + catalog_items(catalog) + problems = judge(items) + return items, problems + + +def gate(mode, root): + try: + items = {"catalog": catalog_items, "controller": controller_items, "hub": hub_items}[mode](root) + except InventoryError as e: + print("upstream gate INCONCLUSIVE: %s" % e) + return 2 + problems = judge(items) + for p in problems: + print(" FAIL %s" % p) + if problems: + print("upstream gate FAILED: %d of %d pins have no usable upstream line (security watch, felhom.eu R-1303). " + "Grammar: felhom.eu/scripts/security_watch_inventory.py; NEW-APP-CHECKLIST.md 1.10." % (len(problems), len(items))) + return 1 + gaps = sum(1 for it in items if any(k == "gap" for k, _ in it["sources"])) + print("upstream gate OK: %d pins, every one names its upstream (%d stated gaps)" % (len(items), gaps)) + return 0 + + +def main(argv): + args = [a for a in argv if not a.startswith("--")] + flags = [a for a in argv if a.startswith("--")] + g = [f.split("=", 1)[1] for f in flags if f.startswith("--gate=")] + if g: + if g[0] not in ("catalog", "controller", "hub") or len(args) != 1: + print(__doc__) + return 2 + return gate(g[0], args[0]) + if "--list" in flags: + try: + items, problems = full_inventory() + except InventoryError as e: + print("inventory unreadable: %s" % e) + return 2 + if "--json" in flags: + print(json.dumps(items, indent=1, ensure_ascii=False)) + else: + for it in items: + print("%-6s %-22s %-48s %-24s %s" % (it["group"], it["owner"][:22], it["image"][:48], it["tag"][:24], + it.get("sources_text") or "-- MISSING --")) + print("%d pins (%d box/build, %d app); %d problems" % ( + len(items), sum(1 for i in items if i["group"] != "app"), + sum(1 for i in items if i["group"] == "app"), len(problems))) + return 1 if problems else 0 + print(__doc__) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/test_security_watch.py b/scripts/test_security_watch.py new file mode 100644 index 00000000..760dbc51 --- /dev/null +++ b/scripts/test_security_watch.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""test_security_watch.py — the nightly security watch (R-1303) against FAKE source answers. No network. + +What is pinned, each by its consequence (the mail or the class), not by its mechanism: + W1 a GitHub advisory whose range covers our pin -> ADVISORY with the fixed version; the same advisory at the fixed + version -> not ADVISORY (the negative control); Nextcloud's per-branch shape; an unreadable range is LISTED + W2 today's cloudflared case, as a RECORDED answer: the Cloudflare blog item of 2026-10-20 -> ADVISORY for cloudflared; + older than 30 days -> nothing; cloudflared's VULN- release-note marker -> ADVISORY with the fixed version + W3 BEHIND (a newer tag in the same channel, same shape), CURRENT, FLOATING, GAP, a beta never counts as a fix + W4 a source that fails -> NOT CHECKED, printed and counted — never CURRENT + W5 one advisory mails ONCE; again after 7 days while open; a vendor notice never repeats; a closed one is forgotten + W6 a source down one night -> no mail; two nights -> one mail; a third night -> no second mail; back up -> reset + W7 the weekly summary on Monday only (or when the last one is 8+ days old); a seed mails nothing and says what it + would have mailed + W8 the inventory: an image with no upstream line refuses (the gate), a gap needs a reason, the grammar +Red-proofs (2026-10-26): W5 with the `k not in known` test removed (every night mails) -> W5 fails; W6 with the streak +threshold at 1 -> W6 fails; W2 with FEED_DAYS check removed -> W2's old-item case fails; W4 with SourceError swallowed +-> W4 fails. +""" +import datetime +import json +import os +import shutil +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import security_watch as w # noqa: E402 +import security_watch_inventory as inv # noqa: E402 + +fails = [] +ran = 0 + + +def check(name, cond, detail=""): + global ran + ran += 1 + if cond: + print(" ok %s" % name) + else: + fails.append("%s %s" % (name, detail)) + print(" XX %s %s" % (name, detail)) + + +class FakeNet: + """URL prefix -> (status, headers, body) | Exception. POSTs (OSV) answer from `posts`.""" + def __init__(self, routes, posts=None): + self.routes, self.posts, self.gh_token, self.requests, self.seen = routes, posts or {}, "", {}, [] + + def get(self, url, headers=None, timeout=30): + self.seen.append(url) + for pfx, ans in self.routes.items(): + if url.startswith(pfx): + if isinstance(ans, Exception): + raise w.SourceError(str(ans)) + st, h, body = ans + return st, h, body if isinstance(body, bytes) else json.dumps(body).encode() + raise w.SourceError("no route for %s" % url) + + def post_json(self, url, body, timeout=30): + key = (body["package"]["ecosystem"] + "/" + body["package"]["name"], body["version"]) + if key not in self.posts: + raise w.SourceError("no OSV answer for %r" % (key,)) + return self.posts[key] + + +def item(image, tag, sources, group="box", owner="test"): + srcs, problems = inv.parse_sources(sources) + return {"group": group, "owner": owner, "image": image, "tag": tag, "where": "test", "sources": srcs, + "problems": problems} + + +ADV_TRAEFIK = [{"ghsa_id": "GHSA-fh26-gfpp-7xxx", "severity": "high", "summary": "snicheck skips the check", + "html_url": "https://github.com/traefik/traefik/security/advisories/GHSA-fh26-gfpp-7xxx", + "published_at": "2026-10-07T00:00:00Z", "withdrawn_at": None, + "vulnerabilities": [{"package": {"name": "github.com/traefik/traefik/v2"}, + "vulnerable_version_range": "<= v2.11.57", "patched_versions": "v2.11.58"}, + {"package": {"name": "github.com/traefik/traefik/v3"}, + "vulnerable_version_range": ">= v3.0.0, <= v3.7.13", "patched_versions": "v3.7.14"}]}] +TAGS = lambda *n: (200, {}, {"results": [{"name": x} for x in n], "next": None}) # noqa: E731 +EMPTY_FEED = (200, {}, b"") +# The RECORDED answer for today's case (blog.cloudflare.com/rss/, item of 2026-10-20, trimmed). +CF_BLOG = (200, {}, ("<![CDATA[8 eMudhra certificates are compromised. Here's what you need to " + "know]]>https://blog.cloudflare.com/emudhra-certificate-compromise/" + "01M5DK5J81T0VC8YTBR2FKD8A3Tue, 20 Oct 2026 09:00:00 GMT" + "Eight eMudhra intermediate certificates are compromised ... " + "Cloudflare Tunnel (cloudflared): update to 2026.10.2 ...

]]>
" + "Something elsehttps://blog.cloudflare.com/x/x" + "Tue, 20 Oct 2026 09:00:00 GMTWorkers news, no tunnel." + "
").encode()) +CF_NOTES = (200, {}, b"2026.10.4\n- 2026-10-23 VULN-176088: Allowlist debug endpoints on the metrics server\n\n" + b"2026.10.3\n- 2026-10-21 chore: bump debian-images base image\n\n" + b"2026.10.2\n- 2026-10-20 Bump go to 1.27.3 and bump distroless image\n") +TODAY = datetime.date(2026, 10, 26) # a Monday + + +def run_items(items, routes, posts=None, rotate=False, today=TODAY): + w.run(items, FakeNet(routes, posts), today, rotate) + return items + + +def t_classes(): + print("-- W1-W4: the classes") + gh = "https://api.github.com/repos/" + routes = { + gh + "traefik/traefik/": (200, {}, ADV_TRAEFIK), + "https://github.com/traefik/traefik/releases.atom": EMPTY_FEED, + "https://hub.docker.com/v2/repositories/library/traefik/": TAGS("v3.7.13", "v3.7.16", "v3.8.0-rc.1", "v2.11.60"), + gh + "gtsteffaniak/filebrowser/": (200, {}, []), + "https://github.com/gtsteffaniak/filebrowser/releases.atom": (200, {}, ( + "2.0.0-beta" + "fixes CVE-2026-1111").encode()), + "https://hub.docker.com/v2/repositories/gtstef/filebrowser/": TAGS("1.5.6-stable", "1.5.7-stable", "1.6.0-beta"), + gh + "cloudflare/cloudflared/": (200, {}, []), + "https://github.com/cloudflare/cloudflared/releases.atom": EMPTY_FEED, + "https://hub.docker.com/v2/repositories/cloudflare/cloudflared/": TAGS("2026.9.3", "2026.10.3", "2026.10.4"), + "https://blog.cloudflare.com/rss/": CF_BLOG, + "https://raw.githubusercontent.com/cloudflare/cloudflared/": CF_NOTES, + gh + "down/repo/": w.SourceError("timed out"), + "https://hub.docker.com/v2/repositories/library/redis/": TAGS("7-alpine", "7.4.6-alpine"), + "https://hub.docker.com/v2/repositories/library/postgres/": TAGS("16-alpine", "17-alpine"), + gh + "nextcloud/security-advisories/": (200, {}, [ + {"ghsa_id": "GHSA-nc01", "summary": "server", "vulnerabilities": [ + {"package": {"name": "Server"}, "vulnerable_version_range": ">= 32.0.0, >= 33.0.0, >= 34.0.0", + "patched_versions": "32.0.12, 33.0.6, 34.0.1"}]}, + {"ghsa_id": "GHSA-nc02", "summary": "the Deck app", "vulnerabilities": [ + {"package": {"name": "Deck"}, "vulnerable_version_range": "< 99.0.0", "patched_versions": "99.0.0"}]}]), + gh + "nextcloud/server/": (200, {}, []), + "https://github.com/nextcloud/server/releases.atom": EMPTY_FEED, + "https://hub.docker.com/v2/repositories/library/nextcloud/": TAGS("34.0.0-apache", "34.0.4-apache"), + gh + "odd/one/": (200, {}, [{"ghsa_id": "GHSA-odd1", "summary": "odd", "vulnerabilities": [ + {"package": {"name": "x"}, "vulnerable_version_range": "all versions", "patched_versions": None}]}]), + "https://github.com/odd/one/releases.atom": EMPTY_FEED, + "https://hub.docker.com/v2/repositories/odd/one/": TAGS("1.0.0"), + } + posts = {("Go/stdlib", "1.27.2"): {"vulns": [{"id": "GO-2026-6809", "summary": "net/http", "affected": [ + {"ranges": [{"events": [{"introduced": "0"}, {"fixed": "1.27.3"}]}]}]}]}, ("Go/stdlib", "1.27.3"): {}} + tr13 = item("traefik", "v3.7.13", "github:traefik/traefik") + tr16 = item("traefik", "v3.7.16", "github:traefik/traefik") + fb = item("gtstef/filebrowser", "1.5.6-stable", "github:gtsteffaniak/filebrowser") + cf = item("cloudflare/cloudflared", "2026.10.3", "github:cloudflare/cloudflared feed:cloudflare-blog vulnnotes:cloudflared") + down = item("down/one", "1.0.0", "github:down/repo") + red = item("redis", "7-alpine", "github:redis/redis", group="app") + pg = item("postgres", "16-alpine", "gap:PostgreSQL announces on postgresql.org only", group="app") + nc4 = item("nextcloud", "34.0.4-apache", "github:nextcloud/server advisories:nextcloud/security-advisories", group="app") + nc0 = item("nextcloud", "34.0.0-apache", "github:nextcloud/server advisories:nextcloud/security-advisories", group="app") + odd = item("odd/one", "1.0.0", "github:odd/one", group="app") + go2 = item("golang", "1.27.2-trixie", "osv:Go/stdlib", group="build") + go3 = item("golang", "1.27.3-trixie", "osv:Go/stdlib", group="build") + for x in (go2, go3): + x["no_registry"] = False + routes["https://hub.docker.com/v2/repositories/library/golang/"] = TAGS("1.27.2-trixie", "1.27.3-trixie") + items = run_items([tr13, tr16, fb, cf, down, red, pg, nc4, nc0, odd, go2, go3], routes, posts) + check("W1 traefik v3.7.13 -> ADVISORY, fixed v3.7.14", tr13["class"] == "ADVISORY" and + tr13["advisories"][0]["fixed"] == "v3.7.14", tr13.get("advisories")) + check("W1 negative: traefik v3.7.16 -> CURRENT (the rc is no fix, v2 is another channel)", + tr16["class"] == "CURRENT", (tr16["class"], tr16.get("behind"))) + check("W1 Nextcloud 34.0.4 -> no Server advisory (fixed 34.0.1); the Deck app's advisory is not ours", + nc4["class"] == "CURRENT", (nc4["class"], nc4["advisories"])) + check("W1 Nextcloud 34.0.0 -> ADVISORY GHSA-nc01", nc0["class"] == "ADVISORY" and + [a["id"] for a in nc0["advisories"]] == ["GHSA-nc01"], nc0["advisories"]) + check("W1 an unreadable range is LISTED (unsure), never dropped", odd["class"] == "ADVISORY" and + odd["advisories"][0]["unsure"], odd.get("advisories")) + check("W1 OSV: Go 1.27.2 -> GO-2026-6809 fixed 1.27.3; Go 1.27.3 -> none", + go2["class"] == "ADVISORY" and go2["advisories"][0]["fixed"] == "1.27.3" and go3["class"] == "CURRENT", + (go2["class"], go3["class"])) + ids = [a["id"] for a in cf["advisories"]] + check("W2 the recorded Cloudflare blog item -> ADVISORY for cloudflared (and only that item)", + "feed:cloudflare-blog:01M5DK5J81T0VC8YTBR2FKD8A3" in ids and not any(i.endswith(":x") for i in ids), ids) + check("W2 the VULN- marker in 2026.10.4 -> ADVISORY fixed 2026.10.4; 2026.10.2's plain bump is not one", + any(a["fixed"] == "2026.10.4" and "VULN-176088" in a["id"] for a in cf["advisories"]) and + not any("2026.10.2" in a["id"] for a in cf["advisories"]), ids) + cf_old = item("cloudflare/cloudflared", "2026.9.3", "feed:cloudflare-blog") + run_items([cf_old], routes, today=datetime.date(2026, 12, 30)) + check("W2 the same blog item 71 days later -> nothing (30-day window)", not cf_old["advisories"], cf_old["advisories"]) + cf_9 = item("cloudflare/cloudflared", "2026.9.3", "feed:cloudflare-blog") + run_items([cf_9], routes) + check("W2 positive control at the pin it hit: cloudflared 2026.9.3 on 2026-10-26 -> ADVISORY", cf_9["class"] == "ADVISORY") + check("W3 filebrowser 1.5.6-stable -> BEHIND 1.5.7-stable (the beta is no channel, its CVE no fix)", + fb["class"] == "BEHIND" and fb["behind"] == "1.5.7-stable", (fb["class"], fb.get("behind"), fb["advisories"])) + check("W3 redis 7-alpine -> FLOATING", red["class"] == "FLOATING", red["class"]) + check("W3 postgres gap -> GAP (BEHIND still computed: no newer 16-…)", pg["class"] == "GAP", pg["class"]) + check("W4 a source that fails -> NOT CHECKED, with the reason", down["class"] == "NOT CHECKED" and + "timed out" in down["not_checked"][0]["why"], down.get("not_checked")) + lines = w.table(items) + check("W4 the table prints the NOT CHECKED line", any(l.startswith("NOT CHECKED") and "timed out" in l for l in lines)) + + +def adv_item(image, aid, group="box", no_reminder=False): + it = {"image": image, "tag": "1.0.0", "owner": "t", "where": "t", "group": group, "class": "ADVISORY", + "not_checked": [], "advisories": [{"id": aid, "summary": "s", "fixed": "1.0.1", "severity": "high", + "source": "GitHub advisory", "link": "l", "unsure": False}]} + if no_reminder: + it["advisories"][0]["no_reminder"] = True + return it + + +def clean(image): + return {"image": image, "tag": "1.0.0", "owner": "t", "where": "t", "group": "app", "class": "CURRENT", + "not_checked": [], "advisories": []} + + +def down(image, source="github"): + return {"image": image, "tag": "1.0.0", "owner": "t", "where": "t", "group": "app", "class": "NOT CHECKED", + "advisories": [], "not_checked": [{"source": source, "why": "HTTP 503"}]} + + +def kinds(mails, only=None): + return [m["kind"] for m in mails if only is None or m["kind"] == only] + + +def t_memory(): + print("-- W5-W7: memory and mails") + d = lambda n: TODAY + datetime.timedelta(days=n) # noqa: E731 (TODAY is a Monday) + st = {"weekly_last": TODAY.isoformat()} + m1, st, _ = w.plan([adv_item("traefik", "GHSA-1"), clean("x")], st, d(1)) + check("W5 a new advisory mails that night", kinds(m1) == ["advisory"] and "[Felhom security] traefik" in m1[0]["subject"], + kinds(m1)) + check("W5 the mail carries our version, the fixed version, the lane", all(s in m1[0]["text"] for s in + ("Our version: 1.0.0", "Fixed in: 1.0.1", "security-fix lane (B23)"))) + m2, st, _ = w.plan([adv_item("traefik", "GHSA-1")], st, d(2)) + check("W5 the same advisory the next night mails NOTHING", m2 == [], kinds(m2)) + m3, st, _ = w.plan([adv_item("traefik", "GHSA-1")], st, d(8)) + adv3 = [m for m in m3 if m["kind"] == "advisory"] + check("W5 still open 7 days later -> one reminder", len(adv3) == 1 and "REMINDER" in adv3[0]["text"], kinds(m3)) + m4, st, _ = w.plan([adv_item("traefik", "GHSA-1")], st, d(9)) + check("W5 and not again the next night", kinds(m4, "advisory") == [], kinds(m4)) + m5, st, _ = w.plan([adv_item("cloudflared", "feed:x", no_reminder=True)], st, d(10)) + m6, st, _ = w.plan([adv_item("cloudflared", "feed:x", no_reminder=True)], st, d(20)) + check("W5 a vendor notice mails once and never repeats", kinds(m5, "advisory") == ["advisory"] and + kinds(m6, "advisory") == [], (kinds(m5), kinds(m6))) + _, st, _ = w.plan([clean("traefik")], st, d(21)) + check("W5 a pin checked clean forgets its closed advisory", "traefik|GHSA-1" not in st["advisories"], st["advisories"]) + m7, st, _ = w.plan([adv_item("box/prog", "GHSA-9"), adv_item("app/prog", "GHSA-8", group="app")], st, d(22)) + a7 = [m for m in m7 if m["kind"] == "advisory"] + check("W5 box programs first in the subject", a7 and a7[0]["subject"].startswith("[Felhom security] prog"), + a7[0]["subject"] if a7 else "") + print("-- W6: a source down") + st = {"weekly_last": TODAY.isoformat()} + n1, st, _ = w.plan([down("a")], st, d(1)) + n2, st, _ = w.plan([down("a")], st, d(2)) + n3, st, _ = w.plan([down("a")], st, d(3)) + n4, st, _ = w.plan([clean("a")], st, d(4)) + n5, st, _ = w.plan([down("a")], st, d(5)) + check("W6 one night down -> no mail", n1 == [], kinds(n1)) + check("W6 two nights -> one mail naming the source", kinds(n2) == ["not_checked"] and "github" in n2[0]["subject"], kinds(n2)) + check("W6 a third night -> no second mail", n3 == [], kinds(n3)) + check("W6 back up -> the streak resets (one more night down -> no mail)", n4 == [] and n5 == [] and + st["failures"]["github"]["streak"] == 1, st.get("failures")) + print("-- W7: weekly and seed") + wk, st2, _ = w.plan([clean("a")], {"weekly_last": "2026-10-19"}, TODAY) + check("W7 Monday -> the weekly summary", kinds(wk) == ["weekly"], kinds(wk)) + wk2, _, _ = w.plan([clean("a")], st2, TODAY) + check("W7 not twice the same Monday", wk2 == [], kinds(wk2)) + wk3, _, _ = w.plan([clean("a")], {"weekly_last": "2026-10-19"}, d(1)) + check("W7 a Tuesday whose last weekly is 8 days old -> the weekly (a missed Monday)", kinds(wk3) == ["weekly"], kinds(wk3)) + sm, sst, would = w.plan([adv_item("traefik", "GHSA-1"), down("b")], {}, d(1), seed=True) + check("W7 a seed mails nothing, records the advisory, and says what it would have mailed", + sm == [] and "traefik|GHSA-1" in sst["advisories"] and kinds(would) == ["advisory"], (kinds(sm), kinds(would))) + + +def t_inventory(): + print("-- W8: the inventory and its gate") + _, p = inv.parse_sources("gap:") + check("W8 a gap with no reason is a problem", p and "reason" in p[0], p) + s, p = inv.parse_sources('"github:a/b advisories:c/d"') + check("W8 two sources parse", s == [("github", "a/b"), ("advisories", "c/d")] and not p, (s, p)) + _, p = inv.parse_sources("gitlab:a/b") + check("W8 an unknown kind is a problem", p and "unknown" in p[0], p) + ws = tempfile.mkdtemp(prefix="sw-inv-") + try: + d = os.path.join(ws, "templates", "demo") + os.makedirs(d) + open(os.path.join(d, "docker-compose.yml"), "w").write("services:\n a:\n image: demo/a:1.0.0\n b:\n image: demo/b:2.0.0\n") + open(os.path.join(d, ".felhom.yml"), "w").write('display_name: "x"\nupstream:\n demo/a: "github:demo/a"\n # demo/b: "github:demo/b"\n') + r = subprocess.run([sys.executable, os.path.join(HERE, "security_watch_inventory.py"), "--gate=catalog", ws], + capture_output=True, text=True) + check("W8 a decoy pin whose line is only a comment REFUSES (rc 1, names demo/b)", + r.returncode == 1 and "demo/b" in r.stdout, (r.returncode, r.stdout[-300:])) + finally: + shutil.rmtree(ws, ignore_errors=True) + + +def main(): + t_classes() + t_memory() + t_inventory() + print() + if fails: + for f in fails: + print("FAIL: %s" % f) + return 1 + print("security_watch tests OK — %d checks" % ran) + return 0 + + +sys.exit(main())