Skip to content

Require every docker image to come from compose.yaml - #167

Merged
Gerrrt merged 3 commits into
mainfrom
fix/image-pins-from-compose
Aug 30, 2026
Merged

Require every docker image to come from compose.yaml#167
Gerrrt merged 3 commits into
mainfrom
fix/image-pins-from-compose

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes #65.

The first half of #65 was already fixed by #164make backup's bare alpine
is gone, replaced by the profile-gated archiver service. This is the second
half: "widen the CI check so a shell recipe cannot introduce another one."

Two live violations it found

CI had three image checks and #65 walked past all three, because each is a
pattern match and the defect was a pin that was absent rather than wrong. Two
more had got in the same way and were still there:

Site Problem
ci.yml:23 GITLEAKS_IMAGE: zricethezav/gitleaks:v8.24.0 — a tag with no digest, outside compose.yaml where Dependabot cannot see it and make pin-digests cannot re-resolve it. Sitting directly beneath the comment saying image versions are not duplicated there.
docs/runbooks/add-monitored-device.md:29 grafana/alloy:v1.18.1 hardcoded in a fenced bash block. Correct on the day it was written; stale from the next Dependabot bump onward, at which point a newly onboarded host runs a different Alloy from the monitoring host. check_docs.py bans versions in prose (#73) but only inspects inline code spans.

Both contradicted README.md and SECURITY.md's claim that every image carries a
tag and a digest.

The rule

scripts/check_image_pins.py parses rather than greps. Every docker run/pull/create in the Makefile, scripts/*.sh, the workflows and the
runbooks' fenced shell blocks must carry an argument that resolves through
scripts/image-for.sh — a variable traced to an image-for.sh assignment in
the same file, or an inline $(… image-for.sh …).

Deliberately not "the image operand must be a variable":

  • That still passes IMG=alpine; docker run "$IMG", which is the identical
    defect — as is IMG=alpine:3.22@sha256:…, since a digest outside
    compose.yaml is one nothing bumps and nothing re-resolves. Tracing the name
    back is what closes those; being a variable closes nothing.
  • Identifying the operand positionally needs a table of which docker run flags
    consume a value, maintained against Docker's CLI forever, where an unknown one
    silently becomes a false positive. It is already broken here: the first
    positional in snmp-generate is $${flags[@]}, a bash array.

Reasoning and the known limits are in the script's docstring. There is no ignore
mechanism, per the .gitleaksignore argument in check_docs.py.

Success prints the number of invocations examined — a parser that quietly
stopped matching would otherwise report green having checked nothing.

Also

  • Widens the duplication grep past the prom/ and grafana/ prefixes that let
    gitleaks through. Verified: the only other repo-wide hit is an illustrative
    version in pin-digests.sh's header, already filtered as a comment.
  • Wired into scripts/validate.sh and a make check-image-pins target, so local
    and CI stay in step (make validate and CI have drifted apart in both directions #68).
  • gitleaks becomes a scan-profile stub in compose.yaml, like archiver and
    renderer. make up still starts six services. The digest is what
    pin-digests.sh resolved from the registry, not a hand-copied one.

Verification

  • Ran the checker on the unmodified tree first: exactly the 3 known
    violations, correct line numbers, no false positives. It finds 24 invocations
    across 8 files, and the count is unchanged after the fixes.
  • 15 mutation tests, including: reconstructing the original bare alpine;
    IMG=alpine; a digest pinned outside compose.yaml; reverting each of the
    three real fixes; docker compose run must not match; and renaming
    TAR_IMAGET throughout backup-volumes.sh to prove the trace is real
    and not a name allowlist.
  • restore-volumes.sh reports 5 sites, not 6 — the quoted warn " docker run …" advice string at L379 is correctly not treated as a command.
  • Full scripts/validate.sh, yamllint --strict, shellcheck,
    markdownlint-cli2, check_docs.py, check_compose_health.py, and
    pin-digests.sh all clean.
  • The pinned gitleaks reference pulls, command: ["version"] is a valid no-op,
    and the real CI invocation runs.
  • Each of the three commits verified independently green, so there is no red
    window between them.

🤖 Generated with Claude Code

Gerrrt and others added 3 commits August 29, 2026 22:41
The one image reference left outside compose.yaml, sitting directly beneath
the comment in ci.yml saying image versions are not duplicated there. It
carried a tag and no digest, so it contradicted the claim in README.md and
SECURITY.md that every image is pinned by tag and digest.

Being outside compose.yaml is what made it unfixable in place: Dependabot
watches that file, and `make pin-digests` reads that file, so a pin anywhere
else is one nothing bumps and nothing digests. It also survived all three of
CI's image guards — not `:latest`, not a prom/ or grafana/ prefix, not an
`image:` line — which is the same gap #65 was opened about.

So it moves to compose.yaml as a profile-gated stub, the way `archiver` and
`renderer` already are, and ci.yml resolves it with scripts/image-for.sh. The
digest is what scripts/pin-digests.sh resolved from the registry, not a
hand-copied one. `make up` still starts six services.

Refs #65

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runbook for onboarding a Linux host pinned grafana/alloy by hand. It was
correct on the day it was written and would have been wrong from the next
Dependabot bump onward, at which point a newly onboarded host would run a
different Alloy from the one the monitoring host runs.

check_docs.py bans image versions in prose for exactly this reason (#73), but
it only inspects inline code spans, so a version inside a fenced block was
invisible to it. A runbook is a shell recipe that a human executes, and this
one had no more claim to a hand-written pin than a script does.

Refs #65

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI had three image checks and #65 walked past all three, because each is a
pattern match and the defect was a pin that was absent rather than wrong:
`make backup` ran a bare `alpine`, which has no `:latest` to grep for, no
prom/ or grafana/ prefix, and is not an `image:` line in compose.yaml. Both of
the pins fixed in the previous two commits got in the same way.

scripts/check_image_pins.py parses instead. Every docker run/pull/create in
the Makefile, scripts/*.sh, the workflows and the runbooks' fenced shell
blocks must carry an argument that resolves through scripts/image-for.sh —
either a variable traced to an image-for.sh assignment in the same file, or an
inline substitution.

Deliberately not "the image operand must be a variable". That would still pass
`IMG=alpine; docker run "$IMG"`, which is the identical defect, and it would
need a table of which docker flags take a value, maintained against Docker's
CLI forever, where an unknown one becomes a false positive — already broken
here, since the first positional in snmp-generate is a bash array. Tracing the
name back to compose.yaml is what closes the hole; being a variable closes
nothing. The reasoning and the known limits are in the script's docstring.

Success prints the number of invocations examined. A parser that quietly
stopped matching would otherwise report green having checked nothing, which is
a failure mode this repository has been bitten by before.

Also widens the duplication grep past the prom/ and grafana/ prefixes that let
gitleaks through, and wires the check into scripts/validate.sh and a
`make check-image-pins` target so local and CI stay in step (#68).

Verified by reverting each of the three real violations in a scratch copy and
confirming the check goes red, and by reconstructing the original bare
`alpine`, the `IMG=alpine` variant, and a digest pinned outside compose.yaml.

Closes #65

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Gerrrt
Gerrrt merged commit de0e197 into main Aug 30, 2026
3 checks passed
@Gerrrt
Gerrrt deleted the fix/image-pins-from-compose branch August 30, 2026 01:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

make backup uses an unpinned alpine image that evades both CI pinning checks

1 participant