Skip to content

fix(container-run.sh): pin the dev/build container image by digest - #11422

Open
basvandijk wants to merge 24 commits into
masterfrom
bas/pin-container-run-image-by-digest
Open

fix(container-run.sh): pin the dev/build container image by digest#11422
basvandijk wants to merge 24 commits into
masterfrom
bas/pin-container-run-image-by-digest

Conversation

@basvandijk

@basvandijk basvandijk commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

What

Fix a Claude Mythos security finding by forcing ci/container/container-run.sh to pull its image pinned by a digest instead of using a mutable tag.

Why

ci/container/container-run.sh is run by both developers and external repro-check verifiers. To reduce the risk of it pulling in a compromised image it should pull in images pinned by a digest instead of a tag that could be locally mutated.

This also makes it consistent with all other sites that pull images which all pull by digest: .devcontainer/devcontainer.json and .github/workflows/* .

Future work could be to further extend this with only running signed / attested images.

How

  • container-autobuild.yml now writes the image digests to ci/container/ic-dev.digest and ci/container/ic-build.digest.

  • ci/container/container-run.sh will now read the digest file and check if a local image with that digest exists.
    If not it will pull it by that digest.
    If the pull fails or if the container input files have been modified the script will fail unless CONTAINER_RUN_ALLOW_UNPINNED is set to 1 in which case the image is build locally.

  • The test-container-run test-suite in ci-main has been extended with more test cases that check for the various edge conditions.

container-run.sh pulled ghcr.io/dfinity/{ic-dev,ic-build}:<TAG> by mutable
registry tag (TAG is only a hash of the Dockerfile inputs), so anyone with
`packages: write` on the two GHCR packages could re-point the tag and have
their image run as root on every fresh machine, with ~/.ssh, ~/.aws and the
SSH agent bind-mounted (security finding 4006141, CWE-494). Every other
consumer already pins these images by immutable @sha256 digest.

- Commit the two OCI index digests as ci/container/{ic-dev,ic-build}.digest,
  next to TAG; container-autobuild.yml validates and writes them.
- container-run.sh: when the working tree's computed tag matches TAG, the
  only registry fetch is `pull <repo>@<pinned digest>`; the local image must
  list that digest in .RepoDigests; it is aliased to <repo>:<TAG>; `run`
  gets --pull=never last. A malformed/missing pin fails before any network.
  When the tag differs (Dockerfile edited, bot commit not yet pulled) the
  image is built locally, never pulled by tag. If the pinned pull fails, an
  existing local <repo>:<TAG> is reused with a loud warning, else built
  locally. CONTAINER_RUN_REQUIRE_PINNED=1 turns every unverified path into a
  hard error. The local build now passes --container-cmd so build-image.sh
  builds into the store the script runs from.
- ci-main.yml test-container-run: pin-consistency check, TAG-sync gate, and
  legs for a poisoned local tag, a malformed pin, an unknown digest and the
  docker runtime; targets.py and the paths filters include the digest files.
- Docs: CLAUDE.md, ci/container/README.md, run-in-dev-container skill.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On GitHub-hosted runners the runner's uid is 1001, so container-run.sh maps
it to the buildifier user, who cannot read /home/ubuntu; the leg's
`test -f /home/ubuntu/.ic-build-container` therefore failed although the
pinned image was pulled, verified and run. The squatting image is an empty
tar that cannot run anything, so `true` already proves the pinned image
ran; the re-point post-check covers the rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Pin validation misses mutable references, and manual fork PR CI is incorrectly treated as a non-PR run.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Pins dev/build container execution to immutable OCI digests.

Changes:

  • Adds and validates image digest pins.
  • Enforces digest-only pulls with controlled fallbacks.
  • Expands CI checks, tests, filters, and documentation.
File summaries
File Description
ci/scripts/targets.py Treats digest changes as global CI inputs.
ci/container/README.md Documents image pinning and fallbacks.
ci/container/ic-dev.digest Adds the dev image pin.
ci/container/ic-build.digest Adds the build image pin.
ci/container/container-run.sh Implements verified digest-based execution.
.github/workflows/container-autobuild.yml Updates digest pins after builds.
.github/workflows/ci-main.yml Adds pin validation and runtime tests.
.claude/skills/run-in-dev-container/SKILL.md Documents pinned container behavior.
.claude/CLAUDE.md Adds digest maintenance guidance.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/ci-main.yml Outdated
Comment thread .github/workflows/ci-main.yml Outdated
basvandijk and others added 3 commits September 2, 2026 14:44
…rite

Deliberately out-of-sync TAG (the trigger container-autobuild.yml itself
documents). The container inputs are unchanged, so the autobuild rebuilds
and re-pushes the same tag with new digests and its bot commit must restore
TAG and rewrite ci/container/{ic-dev,ic-build}.digest together with the
devcontainer and workflow pins. On this commit test-container-run skips via
the TAG-sync gate with a notice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-pin rewrite

This reverts commit 1bafba7.

The autobuild it triggered (run 33643999697) fails while building
ic-build at the Dockerfile's AFL++ step (`ld: error: unable to find
library -lz`), which is unrelated to this PR: the Dockerfile is unchanged
and the same step fails on master's inputs. No bot commit can land until
that is fixed, so restore TAG to the hash the committed digest pins were
built from, keeping the branch in sync and mergeable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The pin-consistency check now collects every ic-build reference under
  .github/workflows/ and every ic-dev reference in .devcontainer/ (':tag'
  or '@digest', well-formed or not) and requires the whole set to equal the
  committed digest reference, so a reference changed to a mutable tag or a
  malformed digest is rejected instead of being skipped by the regex.
- ci-main.yml gains an optional boolean input 'pull-request' that
  ci-kickoff-manual.yml passes as true, so CI dispatched for a reviewed PR
  (the only CI path for fork PRs, whose autobuild cannot commit TAG and the
  digests) gets the TAG-sync notice-and-skip like a pull_request event,
  while push, merge-queue and release-testing runs keep the hard error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The security-sensitive changes affect privileged container execution, registry trust, and multiple CI workflows.

Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

…_ALLOW_UNPINNED opts out)

Replace the opt-in CONTAINER_RUN_REQUIRE_PINNED with the inverse default:
container-run.sh now refuses to run any image that is not verified against
the committed digest, and CONTAINER_RUN_ALLOW_UNPINNED=1 opts out for one
run (build locally when the working tree has no reviewed pin, or reuse an
existing local image when the pinned pull fails), announced with a warning.

The users exposed by the original finding (fresh machines, external
repro-check verifiers, the cloud-config VM, stale checkouts) are exactly
those who never set an opt-in knob, and a failed pinned pull is better as a
fast, explicit error than as a surprise 30-minute local build. The cost is
one env var for developers editing the Dockerfile, who faced a local build
anyway. CI needs no env any more; the refusal legs now test the default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The opt-out accepts unintended values and the consistency gate misses some mutable image-reference locations.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

.github/workflows/ci-main.yml:885

  • Docker is a supported runtime whose image lookup, digest verification, tagging, and --pull=never paths are changed here, but continue-on-error makes its only CI leg unable to catch regressions. Once the leg has demonstrated green on this PR, make it blocking before merge.
    .github/workflows/ci-main.yml:1006
  • The consistency gate only searches for ic-dev in the devcontainer and ic-build in workflows. A mutable ic-dev:tag added to a workflow—or ic-build:tag added to the devcontainer—therefore passes this check, leaving the pinning invariant unenforced. Search both consumer locations for each image.
  • Files reviewed: 10/10 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread ci/container/container-run.sh Outdated
Comment thread .github/workflows/container-autobuild.yml Outdated
PRs from forks cannot change the container inputs: touching ci/* closes
them automatically (.github/repo_policies/EXTERNAL_CONTRIB_BLACKLIST), so
CI dispatched for a fork PR by ci-kickoff-manual.yml can never see an
out-of-sync ci/container/TAG. The TAG-sync gate therefore keys on the
pull_request event alone again; a manual dispatch with an out-of-sync TAG
is a hard error like every other non-PR run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The opt-out currently accepts any nonempty value, and the autobuild guidance contradicts the fail-closed behavior.

Review details

Suppressed comments (2)

ci/container/container-run.sh:134

  • Every nonempty value enables the security opt-out because all downstream checks use -n/-z. Thus conventional settings such as CONTAINER_RUN_ALLOW_UNPINNED=0 or false silently permit a cached or locally built unverified image, contrary to the documented =1 and fail-closed behavior. Only the exact value 1 should enable this path.
ALLOW_UNPINNED="${CONTAINER_RUN_ALLOW_UNPINNED:-}"

.github/workflows/container-autobuild.yml:49

  • This workflow hint says container-run.sh builds locally while TAG is out of sync, but the new default behavior is to refuse unless the explicit opt-out is set. The current message will send users to a command that still fails; mention the required environment variable.
            echo "Modify ci/container/TAG with a random string to trigger a new build; until this"
            echo "workflow's commit lands, container-run.sh on that commit builds the image locally."
  • Files reviewed: 9/9 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

It has passed on every run of this PR, so the continue-on-error hedge only
hides regressions in the docker-specific code paths now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The opt-out accepts unintended values, and the consistency gate does not inspect both image types across every covered consumer location.

Review details

Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

.github/workflows/ci-main.yml:990

  • This only scans .devcontainer, so a workflow can add a mutable ghcr.io/dfinity/ic-dev:<tag> reference and still pass the pin-consistency gate. Scan both consumer locations to enforce the stated invariant for every ic-dev reference.

This issue also appears on line 996 of the same file.

ci/container/container-run.sh:134

  • The opt-out is enabled by any non-empty value, so CONTAINER_RUN_ALLOW_UNPINNED=0, false, or a typo silently permits an unverified privileged image even though the documented security contract requires exactly =1. Reject values other than 1 so accidental environment configuration remains fail-closed.
ALLOW_UNPINNED="${CONTAINER_RUN_ALLOW_UNPINNED:-}"

.github/workflows/ci-main.yml:996

  • This scans workflows only, so a mutable ic-build:<tag> introduced in devcontainer.json is not checked even though changes there trigger this gate. Include the devcontainer file as well so both image names are pinned across all covered consumers.
          build_refs="$(grep -rhoE "${prefix}ic-build[:@][^\"'[:space:]]+" .github/workflows/ | sort -u)"

.github/workflows/container-autobuild.yml:49

  • This hint says container-run.sh builds automatically while TAG is out of sync, but the new default path refuses to run; a local build only occurs with CONTAINER_RUN_ALLOW_UNPINNED=1. Update the hint so users do not interpret the expected refusal as a failure of the workflow.
            echo "Modify ci/container/TAG with a random string to trigger a new build; until this"
            echo "workflow's commit lands, container-run.sh on that commit builds the image locally."
  • Files reviewed: 9/9 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

- container-run.sh accepts only the exact value 1 for
  CONTAINER_RUN_ALLOW_UNPINNED and refuses any other non-empty value, so 0,
  false or a typo cannot silently disable digest enforcement.
- The pin-consistency check scans both consumer locations (.devcontainer/
  and .github/workflows/) for both image names, so a mutable ic-dev
  reference in a workflow or an ic-build reference in devcontainer.json is
  rejected too.
- The autobuild's forced-rebuild hint now says container-run.sh refuses on
  the out-of-sync commit and names the opt-out for a local build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Tagging failures are silently ignored, and the no-reference CI diagnostic exits prematurely.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

ci/container/container-run.sh:209

  • Do not silently ignore a failed alias operation. If this command fails, a squatting tag remains in place and the verified digest-only image can be removed by docker image prune, even though the script reports success and the PR relies on the tag being re-pointed. Let the failure abort so this postcondition is guaranteed.
  • Files reviewed: 9/9 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread .github/workflows/ci-main.yml Outdated
- The pin-consistency check treats grep's no-match exit as an empty set so
  that, under pipefail, the '<none>' diagnostic and ::error annotation are
  emitted instead of the step aborting silently.
- container-run.sh no longer ignores a failed 'tag' of the verified image:
  the alias re-points a squatting tag and keeps the image out of
  'docker image prune', and the CI post-check relies on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@basvandijk
basvandijk requested a balanced review from Copilot September 2, 2026 21:28
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The cached mutable-tag fallback after a failed digest pull lacks regression coverage.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

.github/workflows/ci-main.yml:919

  • This test starts with an empty image store, so it does not cover the critical fail-closed branch where the pinned pull fails while a mutable-tag image is already cached. The separate poisoned-tag case uses a valid digest, so both tests would still pass if a future change reused the cached tag after a failed pull. Add a case that combines the poisoned local tag with the unknown digest and asserts that container-run.sh refuses rather than running it.
  • Files reviewed: 9/9 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

basvandijk and others added 4 commits September 3, 2026 08:50
Copilot pointed out that the refusal legs of test-container-run all start
from an empty image store, so none of them covered the case where the
pinned pull fails while an image is already cached under the mutable tag.
The poisoned-tag leg uses the real pin, so a regression that fell back to
the cached tag after a failed pull would have passed both.

Add a leg that combines the squatting local tag with an unknown digest
and expects the same refusal. The fast-refusal assertion now snapshots
the image store and container list before the run and requires both to
be unchanged afterwards, so it also catches a cached image being run,
instead of only checking that no ic-dev image exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The security-sensitive privileged-container and CI workflow changes merit final human validation despite no concrete defects found.

Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

basvandijk and others added 2 commits September 3, 2026 10:04
Use bash's redirection form instead of forking cat, the idiom
container-run.sh already uses for the same files. The steps run under the
workflow's default shell, which is bash on the Linux runners.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Assign $1 and $2 to local image and digest variables instead of relying
on a comment on the function's opening line; $1 alone appeared five
times, including in the long error message. No behavioural change, and
the image name still never appears as a literal registry reference, so
container-autobuild.yml's sed rewrite of the workflow is unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The security-critical out-of-sync execution branch lacks automated regression coverage.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

ci/container/container-run.sh:179

  • This out-of-sync branch is never exercised by the new CI matrix: the preceding TAG-sync gate skips every run when the checkout is out of sync. That leaves the fail-closed behavior for edited container inputs—one of the security-critical paths added here—without regression coverage. Add a leg whose setup modifies a hashed input after the sync check and assert that the script refuses by default (and ideally a cached-local-image leg for the explicit opt-out).
  • Files reviewed: 9/9 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot pointed out that the out-of-sync branch, where the container
inputs differ from what ci/container/TAG was built from, was never
exercised: the TAG-sync gate skips every leg on an out-of-sync checkout.

Add two legs whose setup step edits ci/container/Dockerfile after the
gate has passed. By default the script must refuse without pulling or
building anything. With CONTAINER_RUN_ALLOW_UNPINNED=1 it must reuse an
image cached under the computed tag (seeded by re-tagging the pinned
image) and warn that the image is unverified.

To support that, the matrix gains optional allow_unpinned and
expect_warning keys, and the success path captures the output so the
warning can be asserted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The expanded test matrix allocates nine runners for every CI invocation, including unrelated pull requests.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread .github/workflows/ci-main.yml
Copilot pointed out that the matrix job had no job-level condition, so
every ci-main run allocated one runner per leg (nine after the recent
additions) only to skip the steps via the path filter.

Move the checkout, path filter, decision, pin-consistency check and
TAG-sync gate into test-container-run-preflight, a single-runner job
whose `run` output is the sync result. The matrix job needs it and only
exists when that output is 'true', so unrelated PRs allocate one short
runner instead of nine, and the pin and TAG checks run once instead of
once per leg. The per-step in_sync conditions are gone for the same
reason. The legs themselves are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The security-critical privileged-container path is well covered, but the autobuild digest rewrite could not be exercised end-to-end.

Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@basvandijk basvandijk changed the title fix(ci): pin the dev/build container image by digest in container-run.sh fix(container-run.sh): pin the dev/build container image by digest Sep 3, 2026
@basvandijk
basvandijk marked this pull request as ready for review September 3, 2026 12:02
@basvandijk
basvandijk requested a review from a team as a code owner September 3, 2026 12:02
@github-actions github-actions Bot added the @infra label Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants