Skip to content

fix(ci): isolate the lockfix restore and drop the cross-run image cache (#226) - #229

Merged
mforce merged 4 commits into
mainfrom
fix/226-image-cache-trust
Oct 27, 2026
Merged

mforce merged 4 commits into
mainfrom
fix/226-image-cache-trust

Conversation

@mforce

@mforce mforce commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Closes #226.

The compute job of the Dependabot lockfix (#225) restores a Dependabot-authored tree under workflow_run, so it runs in main's cache scope. It holds no credential that can push. Code running during that restore, though, could reach the Actions runtime token and create cache entries that main's own CI reads. Those include the image layers the publish job builds from, and the Trivy database and binary the scan job runs. I accepted that risk for #225 and am closing it here.

Why not just harden the cache key

GitHub scopes caches per branch, and any job on main can create any key that doesn't exist yet. No prefix is reserved for one workflow, so a "trusted" key prefix would close nothing. The fix has to remove the untrusted writer.

Changes

  • The restore runs in a container. The new .github/scripts/lockfix-restore.sh reads the SDK image's @sha256 pin from the trusted Dockerfile, and fails unless it finds exactly one. It runs dotnet restore in that image with no runner environment, no Docker socket, --cap-drop ALL and no-new-privileges. Only the PR checkout and the trusted NuGet config are mounted. The PR checkout's .git is hidden behind an empty tmpfs, so the container can't leave a git config or hook for the runner to run later. The container still has network access, which restore needs for nuget.org. setup-dotnet and the restore on the runner are gone.
  • Staging refuses what the container could leave behind. The new .github/scripts/lockfix-stage.sh copies the locks into the artifact. It refuses a lock whose path goes through a symlink, or that is not a regular file. Its tests are in lockfix-stage.test.mjs, which CI now runs next to the classifier tests.
  • No image-layer cache carries over between runs. ci.yml no longer restores /tmp/.buildx-cache from the Actions cache. The amd64 scan build writes the cache, and the push build reads it in the same job, so the job still pushes the amd64 layers it scanned and booted. Before this change, only the amd64 build wrote that cache, so arm64 built fresh then too; that is unchanged. On the main runs the design looked at (39485202541, 39550235125, 40390728311, 40422300059), the amd64 build took 65 to 73 s with the cache, and restoring plus saving the cache took 13 to 28 s. Those are different commits on different runners, so they don't measure the saving exactly. The change also stops adding a new cache entry of about 940 MB per main run. Existing entries stay until they are evicted or deleted.
  • Docs. The workflow's security-model comment, the cache comments in ci.yml and docs/decisions/113-releases.md now describe the new setup. The decision record also no longer says arm64 builds from the cache, which did not match the workflow before this change either.

Verification

  • Replay of chore(deps): Bump the nuget-minor-patch group with 10 updates #223. With the containerized restore, I ran the lockfix's steps on Dependabot's commit 89a16da. The repaired tree equals the hand fix 0782b5e, and a second run changes nothing. Re-run after adding the capability flags, with the same result.
  • Isolation probe. A hostile MSBuild target planted in the PR checkout ran during a real restore. It found none of the runner's environment variables the probe checked for, no host processes, no Docker socket, and no trusted checkout. The only trusted file it can read is the NuGet config, mounted read-only. Its write to .git/config never reached the runner, and staging refused the symlinked lock it planted. A control run with the isolation broken on purpose showed four leaks (a planted ACTIONS_RUNTIME_TOKEN, the Docker socket, a host process and the trusted directory) and failed. The control did not remove the .git mask, so it does not demonstrate that boundary.
  • Image cache. Locally, a second amd64 cache-only docker buildx build on a fresh builder, reading only the cache the first build wrote, reused every Dockerfile step except the base-image pulls. The real push build, which also builds arm64 and pushes, runs only on GitHub.
  • Tests and lint. node --test passes 23 of 23 across the two lockfix test files. shellcheck and actionlint are clean on the changed files.
  • Review. Two local review rounds ran before the push. Round 1 re-ran the container probe itself: no runtime token, no socket, its own process namespace, an unwritable .git, and refused hardlinks and device nodes. It found no defects. I applied two of its nits, the capability flags and the arm64 wording. Round 2 reviewed those and found no defects.

Only GitHub can show

  • The first real lockfix run on a Dependabot NuGet PR: the image pull on a hosted runner, and its timing.
  • The first main run with this change: the arm64 half and the push.

compute ran dotnet restore of a Dependabot tree on the runner host, in
main's cache scope, where restore-time code could reach the Actions
runtime token through a later action. The restore now runs in the SDK
image the default branch's Dockerfile pins, with no runner env, no
docker socket, only pr/ and the restore config mounted, and pr/.git
hidden. Staging refuses a lock path that goes through a symlink, since
the container now writes pr/.

Refs #226
Any job on main can create a cache entry main's image job reads, and the
restore-keys ladder took the newest match. The layer cache now lives
only within the image job: build 1 writes it and the push build reads
it, so the pushed amd64 layers are still the scanned ones.

Refs #226
@mforce
mforce merged commit 0ba0c1d into main Oct 27, 2026
10 checks passed
@mforce
mforce deleted the fix/226-image-cache-trust branch October 27, 2026 00:43
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.

Harden the image-layer cache so only trusted jobs feed published images

1 participant