security watch: seven pins it can never read are a stated GAP, so the source streaks reset; a read timeout is retried; every failed source is printed (R-1334); R-1310's rules (another package, every GHSA a release names, a word-only line, date tags)
gates / gates (push) Successful in 3m35s

This commit is contained in:
admin committed 2026-11-02 22:38:51 +01:00
1 parent c25b790ce9
commit 4afa8c3cde
12 files changed
+2202 -35

No files matched your search

+181 -3
View File
@@ -25,6 +25,13 @@ What is pinned, each by its consequence (the mail or the class), not by its mech
W14 Docker Hub (R-1334): a 429 is retried (Retry-After honoured) and is NOT CHECKED only after the last try; a tag list
cached under 24 h is not fetched, an older one is; two names of one path fetch once; requests are spaced
W11 ghcr.io tag lists are PAGED (Link: rel="next"): every page is read, so a newer tag on page 2 is seen
W15 a one-off read timeout (the 2026-10-29..11-02 nights' cause, R-1334) is tried again on every source; a timeout on
every try is NOT CHECKED; a pin the watch can never read (our own private registry, a stated Docker Hub 401) is a
GAP with its reason, opens no failure streak, and a real outage of that source mails; an unstated 401 stays NOT
CHECKED; every failed source is printed, even for an ADVISORY pin; the RUNTIME line counts a failed resolve
W16 R-1310: another program's package in the repo is not listed; every GHSA a release names is its own entry, with the
lowest fixing tag, never twice; a line where „security” is only a word is no ADVISORY; a YY.MM.YYYYMMDD tag is not
„newer"
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. W9 (2026-10-27, R-1310) was seen failing before its two rules existed (2 of 4 checks red, the two
@@ -439,8 +446,9 @@ def t_dooplex():
priv[0]["sources"] = [("gap", "DooPlex workload")]
w.run(priv, FakeNet({}), TODAY, False, runtime=lambda ref: {"ref": ref, "unchecked": [
"registry gitea.dooplex.hu: no anonymous token route"]})
check("W12 a DooPlex image in a private registry -> NOT CHECKED with the reason", priv[0]["class"] == "NOT CHECKED" and
"gitea.dooplex.hu" in priv[0]["not_checked"][0]["why"], (priv[0]["class"], priv[0]["not_checked"]))
check("W12 a DooPlex image in our own private registry -> GAP with the reason, no failed source (R-1334)",
priv[0]["class"] == "GAP" and not priv[0]["not_checked"] and "gitea.dooplex.hu" in priv[0].get("gap_why", ""),
(priv[0]["class"], priv[0]["not_checked"]))
wk = w.weekly_mail([dict(it, behind="5.4.1", **{"class": "BEHIND"})], TODAY)
check("W12 the weekly mail has its own DooPlex block", "DooPlex (homelab)" in wk["text"], wk["text"][:300])
@@ -536,6 +544,176 @@ def t_dockerhub():
"tag_cache=tcache)" in src_text and "felhom-watch-tags.json" in src_text and "TAGS fetched=" in src_text)
def t_timeouts_and_stated():
print("-- W15: a timeout is tried again; a stated unreadable pin is a GAP, not a failed source (R-1334)")
hub = "https://hub.docker.com/v2/repositories/library/redis/tags"
ok = (200, {}, {"results": [{"name": "7.4.1"}], "next": None})
class FlakyNet(SeqNet):
"""The first `n` GETs of a URL prefix raise the transport error the nights logged."""
def __init__(self, routes, flaky, n=1):
SeqNet.__init__(self, routes)
self.flaky, self.left = flaky, n
def get(self, url, headers=None, timeout=30):
if url.startswith(self.flaky) and self.left > 0:
self.left -= 1
self.seen.append(url)
raise w.Transient("%s: The read operation timed out" % url)
return SeqNet.get(self, url, headers, timeout)
def mk(net):
return w.Sources(net, TODAY, False, sleep=lambda s: None, clock=lambda: 0.0)
net = FlakyNet({hub: [ok]}, hub)
check("W15 a Docker Hub read timeout is tried again and the retry's tags are used",
mk(net).tags("redis") == ["7.4.1"] and len(net.seen) == 2, net.seen)
net = FlakyNet({hub: [ok]}, hub, n=99)
try:
mk(net).tags("redis")
res = "no error"
except w.SourceError as e:
res = str(e)
check("W15 a timeout on every try -> NOT CHECKED after the last try", "timed out" in res and
len(net.seen) == w.HUB_TRIES, (res, len(net.seen)))
feed = "https://github.com/ex/app/releases.atom"
net = FlakyNet({feed: EMPTY_FEED}, feed)
check("W15 a release-feed read timeout is tried again (nginx, 2026-10-29)", mk(net).release_feed("ex/app") == [] and
len(net.seen) == 2, net.seen)
ghcr = "https://ghcr.io/"
net = SeqNet({ghcr + "token": (200, {}, {"token": "t"}),
ghcr + "v2/ex/app/tags/list": [(429, {"Retry-After": "1"}, b""), (200, {}, {"tags": ["1.0.0"]})]})
check("W15 a ghcr.io tag list answering 429 is tried again (2026-11-02: immich-server)",
mk(net).tags("ghcr.io/ex/app") == ["1.0.0"] and len(net.seen) == 3, net.seen)
net = SeqNet({"https://api.github.com/": [(403, {"X-RateLimit-Remaining": "0"}, b""), (200, {}, [])]})
try:
mk(net).gh_advisories("ex/app")
res = "no error"
except w.SourceError as e:
res = str(e)
check("W15 GitHub's spent quota is not waited on (control)", "rate limit" in res and len(net.seen) == 1, (res, net.seen))
api = "https://api.github.com/repos/ex/app/security-advisories"
net = FlakyNet({api: (200, {}, [])}, api)
check("W15 a GitHub advisories read timeout is tried again", mk(net).gh_advisories("ex/app") == [] and
len(net.seen) == 2, net.seen)
# minio's shape: the tag list and the manifest both answer 401, every night
def minio():
i = item("minio/minio", "latest", "gap:DooPlex workload", group="dooplex")
i["sources"] = [("gap", "DooPlex workload")]
return i
auth = "https://auth.docker.io/token"
r401 = {"https://hub.docker.com/v2/repositories/minio/minio/tags": (404, {}, b""), auth: (200, {}, {"token": "t"}),
"https://registry-1.docker.io/v2/minio/minio/tags/list": (401, {}, b"")}
read401 = lambda ref: {"ref": ref, "unchecked": ["manifest minio/minio:latest: HTTP 401"]} # noqa: E731
pins = [minio()]
w.run(pins, FakeNet(r401), TODAY, False, runtime=read401)
check("W15 a stated unreadable pin (minio: 401 on tags and manifest) -> GAP with its reason, no failed source",
pins[0]["class"] == "GAP" and not pins[0]["not_checked"] and "401" in pins[0].get("gap_why", ""),
(pins[0]["class"], pins[0]["not_checked"]))
wk = w.weekly_mail(pins, TODAY)
check("W15 the weekly lists it with its reason (a stated hole, not a silent one)",
"minio/minio" in wk["text"] and "401" in wk["text"], wk["text"])
other = [dict(minio(), image="ex/other")]
other[0]["sources"] = [("gap", "DooPlex workload")]
rother = {"https://hub.docker.com/v2/repositories/ex/other/tags": (401, {}, b"")}
w.run(other, FakeNet(rother), TODAY, False, runtime=lambda ref: {"ref": ref, "unchecked": ["manifest ex/other:latest: HTTP 401"]})
check("W15 an UNSTATED 401 stays NOT CHECKED (a Hub outage is never a GAP)",
other[0]["class"] == "NOT CHECKED" and len(other[0]["not_checked"]) == 2, (other[0]["class"], other[0]["not_checked"]))
timeout = [minio()]
w.run(timeout, FakeNet({"https://hub.docker.com/v2/repositories/minio/minio/tags": RuntimeError("The read operation timed out")}),
TODAY, False, runtime=read401)
check("W15 the stated pin failing ANOTHER way (a timeout) is still NOT CHECKED",
timeout[0]["class"] == "NOT CHECKED", (timeout[0]["class"], timeout[0]["not_checked"]))
# the consequence: the 2026-10-28..11-02 streak was held open by these pins alone, so a real outage mailed nothing
state = {"weekly_last": TODAY.isoformat(), "failures": {"runtime": {"streak": 6, "mailed": True},
"registry:docker.io": {"streak": 7, "mailed": True}}}
pins = [minio()]
w.run(pins, FakeNet(r401), TODAY, False, runtime=read401)
_, st, _ = w.plan(pins, state, TODAY)
check("W15 with only the stated pins left, the memory's `failures` empties (the streak resets)",
st["failures"] == {}, st["failures"])
down = [clean("ex/a")]
down[0]["not_checked"] = [{"source": "registry:docker.io", "why": "HTTP 500"}]
m1, st, _ = w.plan(down, st, TODAY)
m2, st, _ = w.plan(down, st, TODAY + datetime.timedelta(days=1))
check("W15 after that reset a real outage of the source mails on its second night", kinds(m1, "not_checked") == [] and
len(kinds(m2, "not_checked")) == 1, (kinds(m1), kinds(m2)))
# the log tells the truth
adv = [adv_item("ex/a", "CVE-1")]
adv[0]["not_checked"] = [{"source": "runtime", "why": "manifest ex/a:1: HTTP 500"}]
check("W15 a pin with an advisory AND a failed source is printed (the table shows only its class)",
any("runtime" in ln and "ex/a" in ln and "HTTP 500" in ln for ln in w.failed_lines(adv)), w.failed_lines(adv))
r = w.cached_runtime_reader({}, resolve=lambda ref: (_ for _ in ()).throw(w.image_runtime.Unreadable("manifest x:1: HTTP 500")),
read=lambda ref: {})
r("x:1")
check("W15 a failed resolve counts in the RUNTIME line's not-checked (it said 0 for six nights)",
r.stats["not_checked"] == 1, r.stats)
calls = []
def flaky_resolve(ref):
calls.append(ref)
if len(calls) == 1:
raise TimeoutError("timed out")
return "sha256:abc"
r = w.cached_runtime_reader({"sha256:abc": {"ok": 1}}, resolve=flaky_resolve, read=lambda ref: {})
res = r("x:1")
check("W15 a resolve timeout is tried once more before NOT CHECKED", not res.get("unchecked") and len(calls) == 2, (res, calls))
def t_r1310():
print("-- W16: notices that do not apply, and fixes that were missed (R-1310)")
gh = lambda name, rng="<= 2.0.0", fx="2.0.1": {"package": {"name": name}, "vulnerable_version_range": rng, "patched_versions": fx} # noqa: E731
hit, _ = w.ghsa_affects({"vulnerabilities": [gh("gitea.com/gitea/runner")]}, (1, 0, 0), "go-gitea/gitea")
check("W16 another program's package in the repo (Gitea's runner) is not listed", hit is False, hit)
hit, _ = w.ghsa_affects({"vulnerabilities": [gh("gitea.com/gitea/runner"), gh("gitea.dev")]}, (1, 0, 0), "go-gitea/gitea")
check("W16 the same notice also naming the pinned program IS listed (control)", hit is True, hit)
hit, _ = w.ghsa_affects({"vulnerabilities": [gh("gitea.dev")]}, (1, 0, 0), "go-gitea/gitea")
check("W16 the pinned program's package is listed (control)", hit is True, hit)
hit, _ = w.ghsa_affects({"vulnerabilities": [gh("Rootless Docker Image only")]}, (1, 0, 0), "louislam/uptime-kuma")
check("W16 Uptime Kuma's rootless-only notice is not listed for our image", hit is False, hit)
def feed(*entries):
out = "<feed>"
for tag, body in entries:
out += '<entry><link href="https://github.com/ex/app/releases/tag/%s"/><title>%s</title><content>%s</content></entry>' % (tag, tag, body)
return (200, {}, (out + "</feed>").encode())
routes = lambda f: {"https://api.github.com/repos/ex/app/": (200, {}, []), "https://github.com/ex/app/releases.atom": f, # noqa: E731
"https://hub.docker.com/v2/repositories/ex/app/tags": TAGS("1.0.0")}
# Tandoor 2.7.0's shape: eight ids, one line each
pins = run_items([item("ex/app", "1.0.0", "github:ex/app")], routes(feed(
("1.0.2", "Fixes GHSA-aaaa-bbbb-cccc and GHSA-dddd-eeee-ffff."),
("1.0.1", "Security: GHSA-aaaa-bbbb-cccc"))))
ids = sorted(a["id"] for a in pins[0]["advisories"])
check("W16 every GHSA a release names is its own entry, once", ids == ["GHSA-aaaa-bbbb-cccc", "GHSA-dddd-eeee-ffff"], ids)
fx = {a["id"]: a["fixed"] for a in pins[0]["advisories"]}
check("W16 ... with the lowest release that names it as the fix", fx.get("GHSA-aaaa-bbbb-cccc") == "1.0.1" and
fx.get("GHSA-dddd-eeee-ffff") == "1.0.2", fx)
pub = dict(ADV_TRAEFIK[0], ghsa_id="GHSA-aaaa-bbbb-cccc", vulnerabilities=[gh("ex/app", "<= 1.0.0", "1.0.1")])
pins = run_items([item("ex/app", "1.0.0", "github:ex/app")], dict(routes(feed(("1.0.1", "Fixes GHSA-aaaa-bbbb-cccc"))),
**{"https://api.github.com/repos/ex/app/": (200, {}, [pub])}))
check("W16 a GHSA in both the advisory list and a release is one entry (the advisory's)",
[a["source"] for a in pins[0]["advisories"]] == ["GitHub advisory"], pins[0]["advisories"])
# Traefik v3.7.17's notes: a real id AND a word-only line
trf = "[security] Point security announcements to GitHub advisories (#14099)"
pins = run_items([item("ex/app", "1.0.0", "github:ex/app")], routes(feed(("1.0.1", trf))))
check("W16 a line where „security” is only a word is no ADVISORY (Traefik's announcements line)",
pins[0]["class"] != "ADVISORY", pins[0]["advisories"])
pins = run_items([item("ex/app", "1.0.0", "github:ex/app")], routes(feed(("1.0.1", "Dawarich no longer receives security fixes on this line"))))
check("W16 „no longer receives security fixes” is no ADVISORY (Dawarich)", pins[0]["class"] != "ADVISORY", pins[0]["advisories"])
pins = run_items([item("ex/app", "1.0.0", "github:ex/app")], routes(feed(("1.0.1", "CVE fixed: Advisory GHSA-xv5x-242w-7hf7\n" + trf))))
check("W16 the same notes with a real id ARE listed, by that id (control: Traefik v3.7.17)",
[a["id"] for a in pins[0]["advisories"]] == ["GHSA-xv5x-242w-7hf7"], pins[0]["advisories"])
pins = run_items([item("ex/app", "1.0.0", "github:ex/app")], routes(feed(("1.0.1", "Security Fixes"))))
check("W16 a plain „Security Fixes” heading with no id is still an ADVISORY (control: Vaultwarden's shape)",
pins[0]["class"] == "ADVISORY" or any(True for a in pins[0]["advisories"]), pins[0]["advisories"])
best, _ = w.newest_in_channel("26.09.2", ["26.09.20260929", "26.09.1"])
check("W16 linuxserver's YY.MM.YYYYMMDD tag is not newer than 26.09.2", best is None, best)
best, _ = w.newest_in_channel("26.09.2", ["26.09.20260929", "26.09.3"])
check("W16 a real newer tag beside it still counts (control)", best == "26.09.3", best)
best, _ = w.newest_in_channel("26.09.20260901", ["26.09.20260929"])
check("W16 a pin that IS on the date shape still compares its own shape (control)", best == "26.09.20260929", best)
def main():
t_ranges()
t_classes()
@@ -543,7 +721,7 @@ def main():
t_two_groups()
t_dockerhub()
t_inventory()
for t in (t_runtime, t_ghcr_pages, t_dooplex):
for t in (t_runtime, t_ghcr_pages, t_dooplex, t_timeouts_and_stated, t_r1310):
try:
t()
except Exception as e: # noqa: BLE001 — a missing feature is a red check, not a crash