security(compose): pin docker-compose images by digest #185

Open
opened 2026-09-12 12:50:55 +00:00 by PlasticDigits · 0 comments

Summary

Repo-root docker-compose.yml (Dev/QA local stack only) still pulls mutable tags for Foundry Anvil and LocalTerra. On main as of 2026-09-12:

  • anvil and anvil1 → ghcr.io/foundry-rs/foundry:latest
  • localterra and terrad-cli → ghcr.io/plasticdigits/localterra-cl8y:latest

:latest is retaggable. A GHCR push or account takeover can change what docker compose up runs without a git diff. Foundry is the local EVM compiler/runtime used for Anvil and forges; LocalTerra is the local Terra Classic node and terrad CLI image. Poisoned images can change chain behavior, precompiles, or CLI binaries used by QA and E2E.

This is not a production funds path. The compose file publishes only to 127.0.0.1. Operator/canceler production images are a different pipeline. Still a supply-chain defect in the checked-in Dev/QA stack, rated high by internal review (FE-H3).

Other services in the same file use version tags without digests (solanalabs/solana:v1.18.26, postgres:16-alpine, prom/prometheus:v2.48.0, grafana/grafana:10.2.2). Those are not :latest, but they also move if the tag is retargeted. Bundle them so a “compose images must be digest-pinned” CI check is honest.

This is not #172 (Woodpecker .woodpecker.yaml already digest-pins CI runner images). This is not #67 (LocalTerra host port remap). Do not reopen those for this.

Internal review id: FE-H3 (high). Still in source 2026-09-12.

Bundle (same ticket, do not split):

  1. Pin every Compose image: in this repo’s Dev/QA compose files by digest (name:tag@sha256:<64 hex>). Required for Foundry and localterra-cl8y; include Solana, Postgres, Prometheus, and Grafana so the check does not have exceptions.
  2. Add a CI check that fails if any Compose image: line lacks a digest pin (comments and blank lines ignored).
  3. Keep bind addresses, healthchecks, profiles (e2e, monitoring), and port env remaps unchanged.

Supply-chain / deploy-adjacent. No community autoland. Do not add ready.

Impact (today vs hypothetical)

Funds are not at risk today on the live bridge operator, vaults, or on-chain programs from this file. Compose is Dev/QA. Ports bind to loopback. This is not a drain path.

Hypothetical, sticky until pinned:

  • Registry or publisher compromise retags :latest (or a floating version tag). Next docker compose pull / up on a workstation or QA host runs a different Anvil, terrad, validator, or Postgres than the git revision implies.
  • A malicious Foundry image can alter Anvil (precompiles, cast, forge) used for local EVM deploy and E2E. Tests can go green on a lie.
  • A malicious LocalTerra image can alter LCD/RPC, genesis, or terrad used by E2E. That is QA integrity, not mainnet mint.

Today vs hypothetical: the defect is in source today (unpinned tags). Exploitation needs registry control or a malicious republish of those tags, not a permissionless mainnet call. Treat as High for autoland and community because it is a live supply-chain finding, not because vault inventory is reachable from Compose.

Do not publish a GHCR tag-swap recipe or a working malicious image.

Invariants

  • INV-COMPOSE-D1 (new): Every Compose image: value in this repo’s Dev/QA compose files is repository:tag@sha256:<64 lowercase hex>. Tag-only (:latest, :v1.18.26, :16-alpine) is forbidden.
  • INV-COMPOSE-D2 (new): Foundry Anvil services (anvil, anvil1) share one digest-pinned Foundry image unless a documented reason requires two. localterra and terrad-cli share one digest-pinned localterra-cl8y image (they already comment that they use the same image).
  • INV-COMPOSE-D3 (new): CI fails closed when a new image: is added without a digest. A comment must not satisfy the check.
  • Do not change loopback publishes, healthchecks, Solana clone flags, or LocalTerra “no named volume” behavior (#84 wSOL clone stays).
  • Do not pin production operator/frontend Coolify images here. Do not weaken .woodpecker.yaml digest pins from #172 / #174.

Constraints / guardrails

  • Resolve digests at implement time from the registries (docker buildx imagetools inspect / equivalent). Do not invent hashes in review comments. Prefer keeping the current intended tag name and appending @sha256:… so humans still see foundry:latest vs a surprise major bump; the digest is the pin.
  • If Foundry or LocalTerra :latest has moved since the last known-good QA, pin the digest that matches current working QA (same Anvil flags and LocalTerra chain-id localterra), and record the tag+digest in the PR description. Do not silently switch major versions.
  • CI check: a small script (e.g. scripts/check_compose_image_digests.sh) that parses image: lines in docker-compose.yml and any docker-compose*.yml / compose*.yml shipped in-repo. Require @sha256:[0-9a-f]{64}. Fail on :latest even if a digest is also present in a comment. Do not shell-out to docker pull in the check (digest presence is the gate; content trust is the pin).
  • Wire the script into Woodpecker (cheap grep step; can reuse an existing digest-pinned image already in .woodpecker.yaml) and into .github/workflows/test.yml if that workflow is still the merge gate for contributors without Woodpecker.
  • Do not expand into Docker Content Trust / cosign/keyless policy in this ticket. Digest pin + CI grep is the FE-H3 fix.
  • Do not commit registry credentials. Do not paste workstation pull secrets. Do not change Postgres default password handling beyond the image pin.
  • Founder-required deploy / supply-chain pin. No community autoland. Do not add ready.

Relevant files

Path Why
docker-compose.yml Unpinned Foundry and LocalTerra; other services version-tagged only
.woodpecker.yaml Existing digest-pinned CI; add the compose grep step
.github/workflows/test.yml Second CI surface if still required for PRs
scripts/check_compose_image_digests.sh (new) Fail-closed parser for image: lines
scripts/qa/README.md (if it names image tags) Document that tags are digest-pinned and how to bump
  1. For each image: in docker-compose.yml, append @sha256:<digest> of the image currently used for QA. Same digest for anvil/anvil1; same digest for localterra/terrad-cli.
  2. Add scripts/check_compose_image_digests.sh: scan compose YAML for image: keys; allow only name:tag@sha256:<64 hex>; ignore # comments; fail with the offending line.
  3. Call that script from Woodpecker (new step) and GitHub Actions if applicable. Do not skip it without a recorded reason in the yaml (same bar as #172’s cargo-audit skip).
  4. Optional one-line note in scripts/qa/README.md: bumping a compose image means updating the digest, not editing :latest.

Acceptance criteria

  • AC1. anvil and anvil1 image: values include @sha256: and do not rely on a floating Foundry tag alone.
  • AC2. localterra and terrad-cli image: values include @sha256: and do not rely on localterra-cl8y:latest alone.
  • AC3. Remaining Compose services in the same files (Solana, Postgres, Prometheus, Grafana) are digest-pinned the same way.
  • AC4. A CI job fails if any in-repo Compose image: lacks @sha256:<64 hex> (prove with a unit fixture or a documented negative grep in the script’s tests).
  • AC5. Loopback ports, healthchecks, e2e / monitoring profiles, and Solana --clone flags unchanged.
  • AC6. No contract, operator, or frontend runtime change.

Test plan (functional paths)

# Path Expect
T1 Inspect docker-compose.yml image: lines Every line is repo:tag@sha256:<64 hex>
T2 Script on current file after pin Exit 0
T3 Fixture YAML with foundry:latest and no digest Script exit ≠ 0
T4 Fixture with digest only in a comment Fail
T5 anvil vs anvil1 Same Foundry digest unless PR explains otherwise
T6 localterra vs terrad-cli Same LocalTerra digest
T7 Woodpecker / GHA step Runs the script; does not docker pull those images as the gate

Test plan (attack, hack, and abuse)

Non-exploitative. File/CI fixtures only. Do not use as a registry-compromise recipe.

# Vector Expect
A1 Reintroduce :latest on Foundry or LocalTerra CI fails
A2 image: repo@sha256:deadbeef (wrong length) Fail (require 64 hex)
A3 Digest in comment, tag-only image: Fail
A4 New compose service with version tag only Fail
A5 Override file docker-compose*.yml with unpinned image (if such a file exists) Same check covers it

Verification criteria

  • Grep: no image:.*:latest without @sha256: on Compose files.
  • Grep: every Compose image: matches @sha256:[0-9a-f]{64}.
  • Script fixtures T3/T4/A1–A4.
  • Do not verify by publishing a hostile GHCR tag or by pulling from an untrusted mirror.
  • Manual QA (docker compose up Anvil + LocalTerra healthy) is enough to confirm the pinned digests still boot; not a funds-path test.

Out of scope

  • #172 Woodpecker enablement / operator redeploy.
  • Cosign / Docker Content Trust / admission controllers.
  • Production Coolify operator/frontend image pins.
  • Changing Anvil flags, LocalTerra genesis, or Solana clone set.
  • Pinning GitHub Actions uses: SHAs (different surface).

First-pass model recommendation

Recommendation: grok-high

Rationale: Security class (supply-chain image pin, internal FE-H3 high) and deploy-adjacent work. Composer is disallowed: High/security, founder-required deploy/keys-adjacent pinning, not a low-risk first pass. Expected files are docker-compose.yml, a new digest-check script, and a Woodpecker/GHA step — file count does not establish safety. Wrong digest can break all local E2E or silently accept a retagged Anvil/LocalTerra. Verify with the fail-closed script and compose image: greps, not by exercising a hostile registry tag.

## Summary Repo-root `docker-compose.yml` (Dev/QA local stack only) still pulls mutable tags for Foundry Anvil and LocalTerra. On `main` as of 2026-09-12: - `anvil` and `anvil1` → `ghcr.io/foundry-rs/foundry:latest` - `localterra` and `terrad-cli` → `ghcr.io/plasticdigits/localterra-cl8y:latest` `:latest` is retaggable. A GHCR push or account takeover can change what `docker compose up` runs without a git diff. Foundry is the local EVM compiler/runtime used for Anvil and forges; LocalTerra is the local Terra Classic node and `terrad` CLI image. Poisoned images can change chain behavior, precompiles, or CLI binaries used by QA and E2E. This is not a production funds path. The compose file publishes only to `127.0.0.1`. Operator/canceler production images are a different pipeline. Still a supply-chain defect in the checked-in Dev/QA stack, rated high by internal review (FE-H3). Other services in the same file use version tags without digests (`solanalabs/solana:v1.18.26`, `postgres:16-alpine`, `prom/prometheus:v2.48.0`, `grafana/grafana:10.2.2`). Those are not `:latest`, but they also move if the tag is retargeted. Bundle them so a “compose images must be digest-pinned” CI check is honest. This is not [#172](https://git.cl8y.com/code/cl8y-bridge-monorepo/issues/172) (Woodpecker `.woodpecker.yaml` already digest-pins CI runner images). This is not [#67](https://git.cl8y.com/code/cl8y-bridge-monorepo/issues/67) (LocalTerra host port remap). Do not reopen those for this. Internal review id: FE-H3 (high). Still in source 2026-09-12. Bundle (same ticket, do not split): 1. Pin every Compose `image:` in this repo’s Dev/QA compose files by digest (`name:tag@sha256:<64 hex>`). Required for Foundry and `localterra-cl8y`; include Solana, Postgres, Prometheus, and Grafana so the check does not have exceptions. 2. Add a CI check that fails if any Compose `image:` line lacks a digest pin (comments and blank lines ignored). 3. Keep bind addresses, healthchecks, profiles (`e2e`, `monitoring`), and port env remaps unchanged. Supply-chain / deploy-adjacent. No community autoland. Do not add `ready`. ## Impact (today vs hypothetical) Funds are not at risk today on the live bridge operator, vaults, or on-chain programs from this file. Compose is Dev/QA. Ports bind to loopback. This is not a drain path. Hypothetical, sticky until pinned: - Registry or publisher compromise retags `:latest` (or a floating version tag). Next `docker compose pull` / `up` on a workstation or QA host runs a different Anvil, terrad, validator, or Postgres than the git revision implies. - A malicious Foundry image can alter Anvil (precompiles, `cast`, forge) used for local EVM deploy and E2E. Tests can go green on a lie. - A malicious LocalTerra image can alter LCD/RPC, genesis, or `terrad` used by E2E. That is QA integrity, not mainnet mint. Today vs hypothetical: the defect is in source today (unpinned tags). Exploitation needs registry control or a malicious republish of those tags, not a permissionless mainnet call. Treat as High for autoland and community because it is a live supply-chain finding, not because vault inventory is reachable from Compose. Do not publish a GHCR tag-swap recipe or a working malicious image. ## Invariants - INV-COMPOSE-D1 (new): Every Compose `image:` value in this repo’s Dev/QA compose files is `repository:tag@sha256:<64 lowercase hex>`. Tag-only (`:latest`, `:v1.18.26`, `:16-alpine`) is forbidden. - INV-COMPOSE-D2 (new): Foundry Anvil services (`anvil`, `anvil1`) share one digest-pinned Foundry image unless a documented reason requires two. `localterra` and `terrad-cli` share one digest-pinned `localterra-cl8y` image (they already comment that they use the same image). - INV-COMPOSE-D3 (new): CI fails closed when a new `image:` is added without a digest. A comment must not satisfy the check. - Do not change loopback publishes, healthchecks, Solana clone flags, or LocalTerra “no named volume” behavior ([#84](https://git.cl8y.com/code/cl8y-bridge-monorepo/issues/84) wSOL clone stays). - Do not pin production operator/frontend Coolify images here. Do not weaken [.woodpecker.yaml](https://git.cl8y.com/code/cl8y-bridge-monorepo/src/branch/main/.woodpecker.yaml) digest pins from #172 / #174. ## Constraints / guardrails - Resolve digests at implement time from the registries (`docker buildx imagetools inspect` / equivalent). Do not invent hashes in review comments. Prefer keeping the current intended tag name **and** appending `@sha256:…` so humans still see `foundry:latest` vs a surprise major bump; the digest is the pin. - If Foundry or LocalTerra `:latest` has moved since the last known-good QA, pin the digest that matches current working QA (same Anvil flags and LocalTerra chain-id `localterra`), and record the tag+digest in the PR description. Do not silently switch major versions. - CI check: a small script (e.g. `scripts/check_compose_image_digests.sh`) that parses `image:` lines in `docker-compose.yml` and any `docker-compose*.yml` / `compose*.yml` shipped in-repo. Require `@sha256:[0-9a-f]{64}`. Fail on `:latest` even if a digest is also present in a comment. Do not shell-out to `docker pull` in the check (digest presence is the gate; content trust is the pin). - Wire the script into Woodpecker (cheap grep step; can reuse an existing digest-pinned image already in `.woodpecker.yaml`) and into `.github/workflows/test.yml` if that workflow is still the merge gate for contributors without Woodpecker. - Do not expand into Docker Content Trust / cosign/keyless policy in this ticket. Digest pin + CI grep is the FE-H3 fix. - Do not commit registry credentials. Do not paste workstation pull secrets. Do not change Postgres default password handling beyond the image pin. - Founder-required deploy / supply-chain pin. No community autoland. Do not add `ready`. ## Relevant files | Path | Why | | --- | --- | | `docker-compose.yml` | Unpinned Foundry and LocalTerra; other services version-tagged only | | `.woodpecker.yaml` | Existing digest-pinned CI; add the compose grep step | | `.github/workflows/test.yml` | Second CI surface if still required for PRs | | `scripts/check_compose_image_digests.sh` (new) | Fail-closed parser for `image:` lines | | `scripts/qa/README.md` (if it names image tags) | Document that tags are digest-pinned and how to bump | ## Recommended direction 1. For each `image:` in `docker-compose.yml`, append `@sha256:<digest>` of the image currently used for QA. Same digest for `anvil`/`anvil1`; same digest for `localterra`/`terrad-cli`. 2. Add `scripts/check_compose_image_digests.sh`: scan compose YAML for `image:` keys; allow only `name:tag@sha256:<64 hex>`; ignore `#` comments; fail with the offending line. 3. Call that script from Woodpecker (new step) and GitHub Actions if applicable. Do not skip it without a recorded reason in the yaml (same bar as #172’s cargo-audit skip). 4. Optional one-line note in `scripts/qa/README.md`: bumping a compose image means updating the digest, not editing `:latest`. ## Acceptance criteria - AC1. `anvil` and `anvil1` `image:` values include `@sha256:` and do not rely on a floating Foundry tag alone. - AC2. `localterra` and `terrad-cli` `image:` values include `@sha256:` and do not rely on `localterra-cl8y:latest` alone. - AC3. Remaining Compose services in the same files (Solana, Postgres, Prometheus, Grafana) are digest-pinned the same way. - AC4. A CI job fails if any in-repo Compose `image:` lacks `@sha256:<64 hex>` (prove with a unit fixture or a documented negative grep in the script’s tests). - AC5. Loopback ports, healthchecks, `e2e` / `monitoring` profiles, and Solana `--clone` flags unchanged. - AC6. No contract, operator, or frontend runtime change. ## Test plan (functional paths) | # | Path | Expect | | --- | --- | --- | | T1 | Inspect `docker-compose.yml` `image:` lines | Every line is `repo:tag@sha256:<64 hex>` | | T2 | Script on current file after pin | Exit 0 | | T3 | Fixture YAML with `foundry:latest` and no digest | Script exit ≠ 0 | | T4 | Fixture with digest only in a comment | Fail | | T5 | `anvil` vs `anvil1` | Same Foundry digest unless PR explains otherwise | | T6 | `localterra` vs `terrad-cli` | Same LocalTerra digest | | T7 | Woodpecker / GHA step | Runs the script; does not `docker pull` those images as the gate | ## Test plan (attack, hack, and abuse) Non-exploitative. File/CI fixtures only. Do not use as a registry-compromise recipe. | # | Vector | Expect | | --- | --- | --- | | A1 | Reintroduce `:latest` on Foundry or LocalTerra | CI fails | | A2 | `image: repo@sha256:deadbeef` (wrong length) | Fail (require 64 hex) | | A3 | Digest in comment, tag-only `image:` | Fail | | A4 | New compose service with version tag only | Fail | | A5 | Override file `docker-compose*.yml` with unpinned image (if such a file exists) | Same check covers it | ## Verification criteria - Grep: no `image:.*:latest` without `@sha256:` on Compose files. - Grep: every Compose `image:` matches `@sha256:[0-9a-f]{64}`. - Script fixtures T3/T4/A1–A4. - Do not verify by publishing a hostile GHCR tag or by pulling from an untrusted mirror. - Manual QA (`docker compose up` Anvil + LocalTerra healthy) is enough to confirm the pinned digests still boot; not a funds-path test. ## Out of scope - [#172](https://git.cl8y.com/code/cl8y-bridge-monorepo/issues/172) Woodpecker enablement / operator redeploy. - Cosign / Docker Content Trust / admission controllers. - Production Coolify operator/frontend image pins. - Changing Anvil flags, LocalTerra genesis, or Solana clone set. - Pinning GitHub Actions `uses:` SHAs (different surface). ## First-pass model recommendation Recommendation: grok-high Rationale: Security class (supply-chain image pin, internal FE-H3 high) and deploy-adjacent work. Composer is disallowed: High/security, founder-required deploy/keys-adjacent pinning, not a low-risk first pass. Expected files are `docker-compose.yml`, a new digest-check script, and a Woodpecker/GHA step — file count does not establish safety. Wrong digest can break all local E2E or silently accept a retagged Anvil/LocalTerra. Verify with the fail-closed script and compose `image:` greps, not by exercising a hostile registry tag.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-bridge-monorepo#185
No description provided.