Skip to content

fix(docker): stop the distro-upgrade layer being cached forever (12 HIGH live in github-actions:latest) - #388

Open
Cre-eD wants to merge 1 commit into
mainfrom
fix/distro-layer-cache-bust
Open

fix(docker): stop the distro-upgrade layer being cached forever (12 HIGH live in github-actions:latest)#388
Cre-eD wants to merge 1 commit into
mainfrom
fix/distro-layer-cache-bust

Conversation

@Cre-eD

@Cre-eD Cre-eD commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

simplecontainer/github-actions:latest ships python3 3.14.5-r0 — 12 HIGH, 32 MEDIUM, 4 LOW across python3 / pyc / python3-pyc / python3-pycache-pyc0 — while Alpine v3.24/main has served 3.14.7-r1 for a while. Our own binary in that image scans 0; every finding is the base OS layer.

The cause is the build cache, not a dependency version, so #387 could not have caught it.

Root cause

Release, staging and preview builds all pass cache-from: type=gha. The final stage's base is digest-pinned, and its apk update && apk upgrade --no-cache && apk add … RUN string never changes. So that layer's cache key is permanently stable — the upgrade has not actually executed since the first ever build of that scope. Straight from the release log for 076224a:

--cache-from type=gha,scope=prod-github-actions --cache-to type=gha,mode=max,scope=prod-github-actions
#13 [stage-1 2/8] RUN apk update && apk upgrade --no-cache     && apk add --no-cache ca-certificates git openssh-client curl jq bash python3 aws-cli ...
#13 CACHED

Confirmed from inside the published image — the package is available, we just never take it:

$ docker run --rm --entrypoint sh simplecontainer/github-actions:latest -c 'apk update -q; apk policy python3'
python3 policy:
  3.14.5-r0:
    lib/apk/db/installed
  3.14.7-r1:
    https://dl-cdn.alpinelinux.org/alpine/v3.24/main

--no-cache on the apk line is unrelated and easy to misread as protection: it governs apk's own index cache, not Docker layers.

caddy and cloud-helpers currently look healthy only by accident#387 changed instructions upstream of their upgrade layers (a new base digest, a new RUN), which busted the chain. kubectl was CACHED too and is clean only because its package set happens to contain nothing vulnerable. Nothing about that is a property we should rely on.

Fix

Name the final stage runtime in all five Dockerfiles, and pass no-cache-filters: runtime to docker/build-push-action in push.yaml, build-staging.yml (both steps) and branch-preview.yaml.

Scoped to the final stage deliberately. The expensive work — the SHA-256-verified Pulumi and gcloud downloads, gcloud components install, and the slimming pass — lives in builder and keeps its cache. This was the open question ("isn't the cache higher priority?"), so it was measured rather than assumed, on github-actions.Dockerfile with a warm builder:

build wall time python3 OS findings
fully cached (today's behaviour) 7.3 s 3.14.5-r0 48
--no-cache-filter runtime 41.1 s 3.14.7-r1 0

+34 s per image. The trust-tier cache scoping added in the earlier hardening work is untouched — cache-from / cache-to and the prod-* / staging-* / preview-* separation are unchanged, so a lower-trust build still cannot write a scope a release build reads.

Verification

All five images built locally with --no-cache-filter runtime, then scanned:

image OS-package findings total remaining findings are
github-actions 0 (was 48) 17 gcloud-crc32c + gke-gcloud-auth-plugin — Google's binaries
github-actions-staging 0 17 same
cloud-helpers 0 0
caddy 0 1 cel-go — needs an upstream Caddy change
kubectl 0 17 upstream kubectl binary

Every remaining finding is an upstream binary we do not compile, each already documented in #387. In-Dockerfile smoke tests (caddy version, plugin greps, pulumi version, gcloud version, gke-auth-plugin presence, aws --version, sc symlink, test -x /cloud-helpers) all pass under the filter. YAML parse-checked on all three workflows.

Why this matters beyond python3

git, openssh-client, curl, libssl3, libcrypto3 and aws-cli sit in the same frozen layer. The next advisory in any of them would have been just as invisible — in the image that runs every consumer's deploy while holding their cloud credentials. The python3 CVEs themselves are mostly DoS plus two tarfile extraction-filter bypasses (CVE-2026-11940, CVE-2026-4360) that need an attacker-controlled archive, and Python's inputs here are gcloud's authenticated Google API traffic — so live exploitability is low. The reason to fix is that the patching mechanism was dead, not the severity of what it happened to be hiding this month.

Not switching to distroless / scratch

Considered and rejected for these images, on evidence rather than preference:

$ head -1 $(command -v aws)            -> #!/usr/bin/python3
$ head -1 /opt/google-cloud-sdk/bin/gcloud -> #!/bin/sh      (needs CLOUDSDK_PYTHON)
$ grep -oE '"(gcloud|aws|git|ssh|bash|sh|pulumi)"' -r pkg/  -> bash gcloud git pulumi sh ssh

aws is a Python script, gcloud is a shell script that needs a Python interpreter, and SC shells out to bash / git / ssh / pulumi / gcloud through Pulumi local.Command (aws s3 sync in the static-website template, gcloud auth configure-docker in the GKE stack). A shell-less, Python-less base cannot run this image — its entire purpose is having those tools on PATH. distroless/python3 would only trade an Alpine base we can patch for a Debian base we can't, minus gcloud, git and ssh.

Scratch is worth considering for cloud-helpers (a single static Go binary, already at 0 findings — the gain would be structural immunity to base drift rather than a fix) and possibly caddy. Both are separate changes and do not belong in a cache fix.

Test plan

  • docker build --no-cache-filter runtime for all five Dockerfiles: build OK, in-Dockerfile smoke tests pass, 0 OS-package findings each.
  • Timing measured with a warm builder cache to confirm the builder stage is unaffected: 7.3 s → 41.1 s.
  • yaml.safe_load on push.yaml, build-staging.yml, branch-preview.yaml.
  • No Go, go.mod or test changes — this is Dockerfile stage naming plus one workflow input.
  • The real proof is the next release: simplecontainer/github-actions:latest should come out with python3 3.14.7-r1 and 0 alpine findings.

`simplecontainer/github-actions:latest` ships python3 3.14.5-r0 -- 12 HIGH,
32 MEDIUM, 4 LOW across python3/pyc/python3-pyc/python3-pycache-pyc0 -- while
Alpine v3.24/main has served 3.14.7-r1 for a while. Our own binary in that image
scans 0; every finding is the base OS layer.

The cause is the build cache, not a dependency version. Release, staging and
preview builds all pass `cache-from: type=gha`, and the final stage's base is
digest-pinned with an `apk update && apk upgrade && apk add ...` RUN string that
never changes. So that layer's cache key is permanently stable and the upgrade
has not actually executed since the first ever build. From the release log for
076224a:

    --cache-from type=gha,scope=prod-github-actions
    #13 [stage-1 2/8] RUN apk update && apk upgrade --no-cache && apk add ...
    #13 CACHED

`--no-cache` on the apk line is not related -- it governs apk's own index cache,
not Docker layers.

Fix: name the final stage `runtime` in all five Dockerfiles and pass
`no-cache-filters: runtime` to docker/build-push-action in push.yaml,
build-staging.yml and branch-preview.yaml.

Scoped to the final stage deliberately. The expensive work -- the SHA-256
verified Pulumi and gcloud downloads, `gcloud components install` and the
slimming pass -- lives in `builder` and keeps its cache. Measured on
github-actions.Dockerfile with a warm builder:

    fully cached (today)            7.3 s   python3 3.14.5-r0   48 os findings
    --no-cache-filter runtime      41.1 s   python3 3.14.7-r1    0 os findings

34 seconds per image. Verified across all five: caddy, cloud-helpers, kubectl,
github-actions and github-actions-staging each build clean with the filter and
report 0 OS-package findings. Remaining findings are upstream binaries we do not
compile -- gcloud-crc32c and gke-gcloud-auth-plugin (17), kubectl (17), cel-go
in the Caddy binary (1) -- all already documented in #387.

Why this matters beyond python3: git, openssh-client, curl, libssl3, libcrypto3
and aws-cli sit in the same frozen layer. The next advisory in any of them would
have been just as invisible, in the image that runs every consumer's deploy
holding their cloud credentials. caddy and cloud-helpers only looked healthy
because #387 happened to change instructions upstream of their upgrade layers,
which busted the chain by accident.

Not switching these to distroless or scratch: `aws` is a `#!/usr/bin/python3`
script, `gcloud` is a shell script that needs a Python interpreter, and SC shells
out to bash, git, ssh, pulumi and gcloud via Pulumi local.Command. A shell-less
base cannot run this image. Scratch is worth considering for cloud-helpers (a
single static Go binary, already at 0 findings) and possibly caddy -- tracked
separately, not mixed into a cache fix.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
@github-actions

Copy link
Copy Markdown

Semgrep Scan Results

Repository: api | Commit: 4fbde89

Check Status Details
⚠️ Semgrep Warning 2 warning(s), 6 total

Scanned at 2026-08-21 18:21 UTC

@github-actions

Copy link
Copy Markdown

Security Scan Results

Repository: api | Commit: 4fbde89

Check Status Details
✅ Secret Scan Pass No secrets detected
✅ Dependencies (Trivy) Pass 1 total (no critical/high)
✅ Dependencies (Grype) Pass 1 total (no critical/high)
📦 SBOM Generated 523 components (CycloneDX)

Scanned at 2026-08-21 18:22 UTC

@github-actions

Copy link
Copy Markdown

📊 Statement coverage

Measured on the documented included set (see docs/TESTING.md → Coverage scope). Observe-only — no regression gate is enforced yet.

Scope This PR main baseline Δ
Included set (Gold-tier denominator) 90.5% 90.5% +0.0 pp
Full set (whole repo, transparency) 29.1% 29.1% +0.0 pp

Baseline: main @ 076224a

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.

1 participant