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
gates / gates (push) Successful in 3m35s
This commit is contained in:
1 parent
c25b790ce9
commit
4afa8c3cde
12 files changed
+2202
-35
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user