Skip to content

security: pin mitmproxy image chain + rtk installer (Phase 1 of #20) - #100

Open
shejnowicz wants to merge 12 commits into
masterfrom
security/20-pin-image-versions
Open

security: pin mitmproxy image chain + rtk installer (Phase 1 of #20)#100
shejnowicz wants to merge 12 commits into
masterfrom
security/20-pin-image-versions

Conversation

@shejnowicz

Copy link
Copy Markdown
Collaborator

Closes #20.

Summary

Removes `latest`-tag supply-chain exposure from sandcat's security-critical image chain. The mitmproxy image sees all agent plaintext and holds real secret values — today every `docker compose pull` silently swaps it for whatever upstream published, and the weekly CI cron overwrites the ghcr `latest` tags that generated projects reference.

What's pinned now (to 12.2.3):

  • `mitmproxy/mitmproxy` in the generated `compose-proxy.yml` (provider `none`)
  • `ghcr.io/virtuslab/sandcat-mitmproxy-op` / `-pass` in `apply_secret_provider` (secret providers)
  • `FROM` in both `images/*/Dockerfile` (via `ARG MITMPROXY_VERSION`, no default)
  • rtk installer: script pinned to a commit SHA and binary pinned via `RTK_VERSION=v0.45.0` (rtk's install.sh SHA-256-verifies the download against the release's checksums)

Two-file source of truth, contract-enforced: `SCT_MITMPROXY_VERSION` in `cli/lib/constants.bash` (ships with the CLI) + `MITMPROXY_VERSION` in `images/mitmproxy.env` (consumed by CI). A contract test (`cli/test/compat/mitmproxy_version.bats`) fails CI when they diverge — and `cli-tests.yml` now triggers on `images/mitmproxy.env` too, so a one-sided bump can't slip through. Bump = 2 lines, documented in `cli/README.md`.

CI channel policy (traced across all 5 event buckets in review):

  • master push (or manual dispatch on master) → publishes `` + `sha` tags; never moves `latest`
  • weekly cron → builds `FROM mitmproxy:latest` and refreshes ONLY the `latest` tag (tracking channel)
  • manual dispatch off-master → branch-name + sha tags only — can never overwrite a canonical version tag
  • PRs → build-only, no push (unchanged)

Deliberate design choices (for reviewer visibility)

  • `latest` refreshes only weekly — master pushes no longer move the ghcr `latest` tags. Chosen deliberately to keep the tracking channel and the pinned channel on separate publishers. Consequence: a Dockerfile hotfix reaches old (`latest`-referencing) projects at the next cron, up to 7 days. If faster reach is ever needed, a channel-select `workflow_dispatch` input is the natural extension.
  • Versioned tags are immutable by convention, not cryptographically — digest pinning is a possible Phase 3. Phase 2 (Renovate automation for bumps) is also out of scope here.
  • Out of scope: base images (`devcontainers/base`, `debian:trixie-slim` — agent-side, inside the sandbox boundary) and agent CLI installers (self-updating tools).

Merge-day sequencing (important)

The ghcr `12.2.3` tags publish only AFTER this merges (the image workflows trigger on this push). Until both workflows go green (~5-25 min), a freshly generated secret-provider project fails `docker compose up` with a loud, retry-safe `manifest unknown`. Provider `none` is unaffected (the public Docker Hub tag exists). Documented in the bump procedure.

Post-merge checklist: confirm both image workflows published `sandcat-mitmproxy-op:12.2.3` and `-pass:12.2.3`, then smoke-test a 1password-provider project.

Test plan

  • Full bats surface green (14 suites), including the new 18-assertion compat suite with the version contract test
  • Hands-on integration (provider `none`): fresh project → compose references `mitmproxy/mitmproxy:12.2.3`, placeholder fully resolved → `up -d --build` pulls the pinned tag, all healthy, running container verified on the pinned image, `curl https://github.com\` → 200 through the pinned proxy
  • Provider `1password`: generated compose references `ghcr.io/...-op:12.2.3`; `docker compose config` renders (pull deferred to post-merge — tag doesn't exist yet, see sequencing above)
  • Local image builds: both Dockerfiles × both build-arg values (`12.2.3`/`latest`) + a pass-cli-pinned build, with in-container smoke checks (`op --version`, `mitmdump --version`, `pass-cli --version`)
  • rtk: pinned raw URL serves (HTTP 200); `v0.45.0` verified as a stable (non-prerelease) release matching the pinned commit
  • Workflow YAMLs validated; tag policy hand-traced per event in both files (identical expressions)

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ

shejnowicz and others added 12 commits August 17, 2026 13:31
Pin the mitmproxy image chain to an exact version (12.2.3): the public
image referenced by generated compose-proxy.yml, and the two derived
ghcr secret-provider images. Two-file source of truth (CLI constants +
images/mitmproxy.env) enforced by a contract test. Weekly cron becomes
a latest-only tracking channel; versioned tags publish only from
master pushes and are never overwritten. rtk install script pinned to
a commit SHA (script-level freeze; binary pin deferred until rtk has
stable releases).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ
#20)

Introduces SCT_MITMPROXY_VERSION="12.2.3" in constants.bash and threads it
through the compose-proxy.yml template placeholder and apply_secret_provider's
1password/protonpass ghcr images, replacing all `:latest` tags on the CLI
side. devcontainer.bash now sources constants.bash directly (it wasn't
transitively sourced through its own dependency chain, only accidentally
via caller ordering in production).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ
…ebuilds latest only (#20)

Adds images/mitmproxy.env as the single source of truth for the pinned
mitmproxy base image version, consumed by both build workflows and both
Dockerfiles, and asserted equal to the CLI-side SCT_MITMPROXY_VERSION by a
new contract test.

Both Dockerfiles take MITMPROXY_VERSION as a build arg with no default
(pin lives in exactly one place) and parameterize their FROM. Both
workflows load the pin into $GITHUB_ENV for all non-scheduled triggers,
override it to `latest` for the weekly schedule, and reshape their
metadata-action tags into three buckets: schedule publishes only `latest`
(tracking channel), push/workflow_dispatch publish the pinned version +
sha as immutable tags, and pull_request keeps its existing ref tag with
no push (unchanged behavior).
The pinned <version> tag was reachable from workflow_dispatch on any ref,
so a manual dispatch off a feature branch could overwrite the canonical
immutable tag with unmerged code. Gate it to push (already master-only)
or workflow_dispatch explicitly on refs/heads/master; off-master dispatch
now falls back to a branch-name + sha tag instead, so manual runs are
never tagless and never collide with the canonical tag.
Pin the rtk install.sh download URL to a specific commit SHA rather than
the master branch, closing the arbitrary-code-via-master attack vector.
The script itself downloads a binary internally, so the final binary is
not fully pinned until rtk publishes stable releases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ
…rkflows

The mitmproxy_version.bats contract test is the only sync enforcement
between cli/lib/constants.bash and images/mitmproxy.env, but
cli-tests.yml only triggered on cli/** — a bump touching only the env
file never ran the test, so image workflows could go green while the
CLI-side constant silently drifted out of sync.

Also add -m1 to both image workflows' grep of MITMPROXY_VERSION so a
duplicate key in the env file can't make the workflow (last-wins) and
the contract test (first-wins, -m1) resolve to different values.

Fixes #20
The comment claimed rtk "has only dev-* pre-releases" so the binary
couldn't be pinned — verified false: `gh api repos/rtk-ai/rtk/releases/latest`
returns v0.45.0 with prerelease: false, and that release's target
commit is the same b34be37 SHA already pinned for install.sh. rtk's
install.sh also supports RTK_VERSION=vX.Y.Z pinning and SHA-256-verifies
the downloaded archive against the release's checksums.txt.

Add RTK_VERSION=v0.45.0 to the install invocation so both the script
(commit SHA) and the binary it fetches (checksum-verified release) are
pinned. Extend rtk.bats to assert the emitted block contains
RTK_VERSION=v so the binary pin can't silently regress. Also update the
README's host-side rtk install instructions to use the same pinned
commit + RTK_VERSION instead of pointing at master.

Fixes #20
cli/README.md's bump procedure was missing the merge-day race: between
merging a version bump and the image workflows publishing the new
versioned ghcr tags, `docker compose up` fails loudly with `manifest
unknown` for the op/pass providers (provider none is unaffected — it
resolves straight to the public mitmproxy/mitmproxy Docker Hub tag).
Document waiting for both workflows before standing up or upgrading a
secret-provider project. Also fix the bump procedure's image name,
which referenced a nonexistent `sandcat-mitmproxy` — the published
images are `sandcat-mitmproxy-op` and `sandcat-mitmproxy-pass`.

Soften "immutable" to reflect that it's a convention, not a guarantee:
a dispatch on master can re-push the same versioned tag, and registry
tags aren't cryptographically frozen without digest pinning.

Fixes #20
These are working documents from the spec-driven-development process,
not project documentation — drop them before merge.
The "Note: Cursor agent support..." paragraph sat between the pinning
docs and the init-command reference since before this branch, but
inserting the mitmproxy bump procedure directly above it made it read
as part of the merge-day-race guidance. Move it into the
`sandcat init devcontainer` section, where `--agent cursor` is
documented and the note actually applies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ
pass-cli.env sits next to its single consumer's Dockerfile; mitmproxy.env
is consumed by BOTH image builds, so it lives in their common parent.
Spell out the placement rule so the asymmetry doesn't read as an accident.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ
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.

security: Pin image versions

1 participant