From d3e14eb961ae20dc98f75e0ec1df657463927d9a Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 6 Oct 2026 01:24:22 +0200 Subject: [PATCH] R-426: image-pins gate gets a decoy suite (and three holes closed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-image-pins.py now refuses a QUOTED `"image":` key (was not read at all), an interpolated `${APP_IMAGE:-nginx}` ref (the tag cannot be read), and `@sha256:` with no 64-hex digest behind it (the label of a pin). It takes --root= (the decoy seam) and accepts the runner's --all. test_gate_decoys.py: 17 image-pins cases — nine facts that must be refused (untagged, a registry port read as a tag, quoted/capital :latest, :edge, a comment claiming a pin, the quoted key, interpolation, a fake digest), seven inert/genuine shapes that must pass, and the real catalog. COVERS gains "image-pins". Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- scripts/check-image-pins.py | 33 ++++++++++++++++--- scripts/test_gate_decoys.py | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/scripts/check-image-pins.py b/scripts/check-image-pins.py index 2cb9e78..c3b99af 100644 --- a/scripts/check-image-pins.py +++ b/scripts/check-image-pins.py @@ -7,10 +7,17 @@ Scans every templates/*/docker-compose.yml `image:` line and fails (exit 1) on: - a floating alias tag (`dev`, `nightly`, `edge`, `main`, `master`), - a missing tag entirely (`image: nginx` → implicit :latest). -A digest reference (`repo@sha256:...`) counts as pinned. Registry ports +A digest reference (`repo@sha256:<64 hex>`) counts as pinned — only a REAL digest: the label +`@sha256:` followed by anything else is not a pin (R-426 decoy). Registry ports (`host:5000/img:1.2`) are handled: the tag is what follows the LAST colon of the LAST path segment. +Also refused (R-426, found by the decoy suite `scripts/test_gate_decoys.py`): a QUOTED key +(`"image": nginx` is the same Compose field and was not read at all), and an INTERPOLATED ref +(`${APP_IMAGE:-nginx}` — the tag the box will run cannot be read from the file; write a literal). + +`--root=` judges another checkout (the decoy suite's seam); default is this repo. + Standing rule (CLAUDE.md): never :latest or untagged images in templates — pin a concrete version tag; deployed apps pin to their running digest. """ @@ -19,7 +26,8 @@ import sys from pathlib import Path BANNED_TAGS = {"latest", "dev", "nightly", "edge", "main", "master"} -IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)") +IMAGE_RE = re.compile(r"^\s*[\"']?image[\"']?\s*:\s*[\"']?([^\s\"'#]+)") +DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$") def check(root: Path) -> int: failures = [] @@ -33,8 +41,14 @@ def check(root: Path) -> int: if not m: continue ref = m.group(1) - if "@sha256:" in ref: - continue # digest-pinned — strongest pin there is + if "$" in ref: + failures.append((f, lineno, ref, "INTERPOLATED ref (the pin cannot be read; write a literal)")) + continue + if "@" in ref: + if DIGEST_RE.search(ref): + continue # digest-pinned — strongest pin there is + failures.append((f, lineno, ref, "NOT A DIGEST (@sha256: needs 64 hex characters)")) + continue last_seg = ref.rsplit("/", 1)[-1] if ":" not in last_seg: failures.append((f, lineno, ref, "NO TAG (implicit :latest)")) @@ -51,4 +65,13 @@ def check(root: Path) -> int: return 0 if __name__ == "__main__": - sys.exit(check(Path(__file__).resolve().parent.parent)) + root = Path(__file__).resolve().parent.parent + for a in sys.argv[1:]: + if a.startswith("--root="): + root = Path(a.split("=", 1)[1]) + elif a == "--all": + pass # the runner passes it to every gate; this gate already reads every template, hidden ones too + else: + print(f"unknown argument: {a} (usage: check-image-pins.py [--root=])", file=sys.stderr) + sys.exit(2) + sys.exit(check(root)) diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index 8de99fe..8f37c1f 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -47,6 +47,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Read by felhom.eu/scripts/decoy_coverage_gate.py, which AST-parses this literal. A gate named here # MUST have a decoy below that has been seen to fail. COVERS = { + "image-pins": "the pin's LABEL without the pin: a registry PORT (`:5000`) read as a tag, `@sha256:` with no digest behind it, a QUOTED `\"image\":` key, an interpolated `${APP_IMAGE:-nginx}`, `:LATEST`/`:latest` in quotes - vs the inert shapes that must pass (a commented `# image: nginx`, an `x-image:` extension field, a README line) and the genuine pins (a tag, a real 64-hex digest, `:latest@sha256:` pinned by its digest, a port with a tag) (R-426)", "engine-major": "the major moved in a comment/env var/README/app image, not on an engine's image: line", "catalog-since": "the date bumped in a comment/README while .felhom.yml's field stayed; or only a comment/env moved, no image (R-452)", "probe-matches-compose": "the probe TARGET resolves by exact name, explicit `container`, or a UNIQUE prefix - an ambiguity is refused, not guessed (R-630); the DEGRADED no-PyYAML mode CI actually runs; the port/path moved in a COMMENT, in traefik's loadbalancer label, in " @@ -688,6 +689,67 @@ def family_gate_cases(): shutil.rmtree(ws, ignore_errors=True) +def image_pins_cases(): + """check-image-pins.py reads templates/*/docker-compose.yml under --root (R-426). + + Each case is ONE planted template in a scratch catalog, so a case's verdict is that line's verdict + and nothing else. The real tree is also judged (it must pass), so a gate that convicts everything + fails here too. + """ + global ran + ws = tempfile.mkdtemp(prefix="catalog-pins-") + DIG = "@sha256:" + "0123456789abcdef" * 4 + try: + def run(name, image_line, expect_rc, must=(), extra_file=None): + global ran + cat = os.path.join(ws, "cat") + shutil.rmtree(cat, ignore_errors=True) + d = os.path.join(cat, "templates", "demo") + os.makedirs(d) + io.open(os.path.join(d, "docker-compose.yml"), "w", encoding="utf-8").write( + "services:\n demo:\n image: demo/demo:1.0\n demo-web:\n" + image_line + "\n" + " restart: unless-stopped\n") + if extra_file: + io.open(os.path.join(d, extra_file[0]), "w", encoding="utf-8").write(extra_file[1]) + r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-image-pins.py"), "--root=" + cat], ROOT) + out = r.stdout + r.stderr + ran += 1 + if r.returncode == expect_rc and all(m in out for m in must): + print(" ok %-52s rc=%d (expected %d)" % ("image-pins: " + name, r.returncode, expect_rc)) + else: + fails.append("image-pins: %s: rc=%d expected %d; missing %s\n%s" % ( + name, r.returncode, expect_rc, [m for m in must if m not in out], out[-600:])) + + # ── THE FACTS: an image the box would pull floating. Each must be REFUSED. ─────────────── + run("FACT: untagged", " image: nginx", 1, ("NO TAG",)) + run("FACT: a registry PORT is not a tag", " image: registry.local:5000/nginx", 1, ("NO TAG",)) + run("FACT: quoted :latest", ' image: "nginx:latest"', 1, ("floating tag :latest",)) + run("FACT: :LATEST in capitals", " image: nginx:LATEST", 1, ("floating tag",)) + run("FACT: a floating alias (:edge)", " image: alpine:edge", 1, ("floating tag :edge",)) + run("FACT: untagged with a comment saying pinned", " image: nginx # pinned 1.27", 1, ("NO TAG",)) + run("FACT: a QUOTED key is the same field", ' "image": nginx', 1, ("NO TAG",)) + run("FACT: an interpolated ref cannot be read", " image: ${APP_IMAGE:-nginx}", 1, ("INTERPOLATED",)) + run("FACT: @sha256: with no digest behind it", " image: nginx@sha256:pinned", 1, ("NOT A DIGEST",)) + # ── INERT / GENUINE: must PASS ─────────────────────────────────────────────────────────── + run("INERT: a commented-out untagged image", " # image: nginx", 0, ("image-pin gate OK",)) + run("INERT: an x- extension field (Compose ignores it)", " x-image: nginx", 0, ("image-pin gate OK",)) + run("INERT: an untagged image in README.md", " image: nginx:1.27.3", 0, ("image-pin gate OK",), + extra_file=("README.md", "image: nginx\n")) + run("GENUINE: a concrete tag", " image: nginx:1.27.3-alpine", 0, ("image-pin gate OK",)) + run("GENUINE: a registry port WITH a tag", " image: registry.local:5000/nginx:1.27", 0, ("image-pin gate OK",)) + run("GENUINE: a real digest", " image: nginx" + DIG, 0, ("image-pin gate OK",)) + run("GENUINE: :latest pinned by its digest", " image: nginx:latest" + DIG, 0, ("image-pin gate OK",)) + # the real catalog must pass too — a gate that convicts everything is not a gate + r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-image-pins.py")], ROOT) + ran += 1 + if r.returncode == 0: + print(" ok %-52s rc=0 (expected 0)" % "image-pins: GENUINE: the real catalog") + else: + fails.append("image-pins: the real catalog was refused rc=%d\n%s" % (r.returncode, (r.stdout + r.stderr)[-600:])) + finally: + shutil.rmtree(ws, ignore_errors=True) + + def mem_sum_cases(): """check-mem-limit-sum.py reads FILES: templates/*/docker-compose.yml + .felhom.yml, via --root (R-758).""" global ran @@ -1311,6 +1373,7 @@ i18n: finally: shutil.rmtree(clone, ignore_errors=True) + image_pins_cases() onboarding_cases() family_gate_cases() mem_sum_cases()