security watch: Docker Hub requests spaced, a 429 retried (NOT CHECKED only after the last try), tag lists cached 24 h per registry path on the runner (R-1334); W14
gates / gates (push) Successful in 4m14s

This commit is contained in:
admin committed 2026-10-28 12:43:38 +01:00
1 parent 1c8f9ef924
commit fe677bae94
4 files changed
+158 -13

No files matched your search

+68 -1
View File
@@ -22,6 +22,8 @@ What is pinned, each by its consequence (the mail or the class), not by its mech
the catalog's lane; a DooPlex image in a private registry is NOT CHECKED with the reason; its own weekly block
W13 one image in the catalog AND on DooPlex (R-1333): a new notice is mailed for both pins; the memory keeps one
entry per group; an old shared key is copied, so nothing is mailed twice; a clean DooPlex pin forgets only its own
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
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
@@ -393,7 +395,7 @@ def t_runtime():
src = open(os.path.join(HERE, "security_watch.py"), encoding="utf-8").read()
check("W10 main() hands the reader to run() (off only with --no-runtime)",
"rreader = None if \"no-runtime\" in opts else cached_runtime_reader(rcache)" in src
and "runtime=rreader)" in src)
and "runtime=rreader, tag_cache=tcache)" in src)
check("W10 the reader counts what it read and from the cache (the log's RUNTIME line)",
r.stats["read"] == 2 and r.stats["cached"] == 1 and r.stats["not_checked"] == 1 and "RUNTIME read=" in src,
r.stats)
@@ -470,11 +472,76 @@ def t_two_groups():
["rommapp/romm|CVE-2026-111497"], sorted(st4["advisories"]))
class SeqNet(FakeNet):
"""FakeNet whose route may be a LIST of answers, served in order (the last one repeats)."""
def get(self, url, headers=None, timeout=30):
for pfx, ans in self.routes.items():
if url.startswith(pfx) and isinstance(ans, list):
self.seen.append(url)
st, h, body = ans.pop(0) if len(ans) > 1 else ans[0]
return st, h, body if isinstance(body, bytes) else json.dumps(body).encode()
return FakeNet.get(self, url, headers, timeout)
def t_dockerhub():
print("-- W14: Docker Hub — retry a 429, cache a tag list for 24 h, one fetch per path, paced (R-1334)")
hub = "https://hub.docker.com/v2/repositories/library/redis/tags"
ok = (200, {}, {"results": [{"name": "7.4.1"}, {"name": "7.4.0"}], "next": None})
r429 = (429, {"Retry-After": "7"}, b"")
slept = []
clock = [1000.0]
def sleep(s):
slept.append(s)
clock[0] += s
def mk(net, cache=None, now=1000.0):
return w.Sources(net, TODAY, False, tag_cache=cache, now=now, sleep=sleep, clock=lambda: clock[0])
net = SeqNet({hub: [r429, ok]})
names = mk(net).tags("redis")
check("W14 a 429 is retried after its Retry-After, and the retry's tags are used",
names == ["7.4.1", "7.4.0"] and len(net.seen) == 2 and 7 in slept, (names, net.seen, slept))
net = SeqNet({hub: [r429]})
try:
mk(net).tags("redis")
res = "no error"
except w.SourceError as e:
res = str(e)
check("W14 a 429 on every try -> NOT CHECKED only after the last try (%d tries)" % w.HUB_TRIES,
"429" in res and len(net.seen) == w.HUB_TRIES, (res, len(net.seen)))
cache = {"docker.io/library/redis": {"at": 1000.0 - 3600, "tags": ["7.4.1"]}}
net = SeqNet({hub: [ok]})
names = mk(net, cache).tags("redis")
check("W14 a tag list cached an hour ago -> no request", names == ["7.4.1"] and net.seen == [], (names, net.seen))
cache = {"docker.io/library/redis": {"at": 1000.0 - 25 * 3600, "tags": ["7.4.0"]}}
net = SeqNet({hub: [ok]})
names = mk(net, cache).tags("redis")
check("W14 a tag list cached 25 h ago -> fetched again, and the cache holds the new list",
names == ["7.4.1", "7.4.0"] and len(net.seen) == 1 and cache["docker.io/library/redis"]["at"] == 1000.0,
(names, net.seen, cache))
lsio = "https://hub.docker.com/v2/repositories/linuxserver/sonarr/tags"
net = SeqNet({lsio: [(200, {}, {"results": [{"name": "4.0.20"}], "next": None})]})
src = mk(net)
a, b = src.tags("lscr.io/linuxserver/sonarr"), src.tags("linuxserver/sonarr")
check("W14 two names of one Docker Hub path -> one fetch", a == b == ["4.0.20"] and len(net.seen) == 1, net.seen)
slept.clear()
net = SeqNet({hub: [ok], lsio: [(200, {}, {"results": [{"name": "4.0.20"}], "next": None})]})
src = mk(net)
src.tags("redis")
src.tags("linuxserver/sonarr")
check("W14 Docker Hub requests are spaced (>= %.1f s apart)" % w.HUB_GAP_S,
slept and abs(sum(slept) - w.HUB_GAP_S) < 0.01, slept)
src_text = open(os.path.join(HERE, "security_watch.py"), encoding="utf-8").read()
check("W14 main() loads the runner's tag cache, hands it to run() and prints the TAGS line",
"tag_cache=tcache)" in src_text and "felhom-watch-tags.json" in src_text and "TAGS fetched=" in src_text)
def main():
t_ranges()
t_classes()
t_memory()
t_two_groups()
t_dockerhub()
t_inventory()
for t in (t_runtime, t_ghcr_pages, t_dooplex):
try: