Build-time secrets and how images leak them

ARG, ENV, history, cache exports and provenance, and the mount that avoids them.

Advanced14 min · lesson 5 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (5 files, 1 KB): multistage.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-int/multistage.tar.gz && tar -xzf multistage.tar.gz, which creates ~/lab/multistage/. SHA-256: 8a24193d30da2d7a651f08e2829fa4a8b8d943f5b271bcb8711aa89deff9e194

A pull-request build log contains this line: #6 [build 3/4] RUN printf '//npm.example.test/:_authToken=%s\n' "npm_FAKE_lab_token_0000" > .npmrc. The Dockerfile never contained the token. It was passed with --build-arg, used only in a builder stage, and the final image is clean. The log is one of six places the value ended up anyway. You'll find all of them with a fake token, then run the build where the same credential ends up in none.

Use the main lab VM. The lesson files go in ~/lab/multistage: three Dockerfiles, a find-token.sh helper and a BuildKit config for the cache section. How stages, --target and RUN --mount work is the subject of "BuildKit builds: stages, cache and mounts" in Docker in depth. Here you'll take that syntax as known and ask a different question. Once a value enters a build, where does it go? The token npm_FAKE_lab_token_0000 isn't a credential for anything. Use a value like it whenever you test for leaks, never a real one.

A build argument in a builder stage

Dockerfile.arg is the pattern you'll find in many repositories. The builder stage takes the registry token as an ARG and writes an .npmrc. It fetches dependencies (a stand-in step here, so the lab needs no registry) and produces an artifact. The final stage copies only that artifact.

Dockerfile.arg
# Unsafe on purpose: shows where a token passed with --build-arg ends up.
FROM alpine:3.24 AS build
ARG NPM_TOKEN
WORKDIR /src
RUN printf '//npm.example.test/:_authToken=%s\n' "$NPM_TOKEN" > .npmrc
RUN test -s .npmrc && echo "dependencies fetched" && mkdir -p /out && echo "app v1" > /out/app.txt
FROM alpine:3.24
COPY --from=build /out/app.txt /app/app.txt
CMD ["cat", "/app/app.txt"]
find-token.sh
#!/bin/sh
# find-token.sh IMAGE STRING: where does STRING appear in a local image?
img=$1 tok=$2
tmp=$(mktemp -d)
docker save "$img" | tar -x -C "$tmp"
echo "history lines: $(docker history --no-trunc --format '{{.CreatedBy}}' "$img" | grep -c -- "$tok")"
echo "config env: $(docker image inspect --format '{{json .Config.Env}}' "$img" | grep -c -- "$tok")"
layers=0 other=0
for b in "$tmp"/blobs/sha256/*; do
if gzip -t "$b" 2>/dev/null; then
gzip -dc "$b" | grep -aq -- "$tok" && layers=$((layers + 1))
else
grep -aq -- "$tok" "$b" && other=$((other + 1))
fi
done
echo "layer blobs: $layers"
echo "json blobs: $other"
rm -rf "$tmp"

find-token.sh checks the four places a local image can carry a string. Those are the history (docker history --no-trunc) and the environment in the image config. Then come the layer blobs and the other JSON blobs (config, manifests, attestations) of a docker save export. Build the image and check it:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker build --progress=plain --no-cache -f Dockerfile.arg \ --build-arg NPM_TOKEN=npm_FAKE_lab_token_0000 -t lab-leak:arg .
#0 building with "default" instance using docker driver ... #6 [build 3/4] RUN printf '//npm.example.test/:_authToken=%s\n' "npm_FAKE_lab_token_0000" > .npmrc #6 DONE 0.1s #7 [build 4/4] RUN test -s .npmrc && echo "dependencies fetched" && mkdir -p /out && echo "app v1" > /out/app.txt #7 0.082 dependencies fetched #7 DONE 0.1s #8 [stage-1 2/2] COPY --from=build /out/app.txt /app/app.txt #8 DONE 0.0s ...
$ ./find-token.sh lab-leak:arg npm_FAKE_lab_token_0000
history lines: 0 config env: 0 layer blobs: 0 json blobs: 0

Step #6 prints the RUN command with $NPM_TOKEN already replaced by its value. Every CI system that stores build logs now stores the token, readable by anyone who can open the job. The build also ended with a SecretsUsedInArgOrEnv warning, cut here. The ENV section below shows that check in full.

The final image itself is clean: zero hits in history, config and blobs. Multi-stage did its job for the image you ship. Everything that follows is about what else your build produced.

The builder stage is one --target away

Teams build the builder stage on its own all the time, to debug a failing build or to run tests in CI. Build it the way they'd do it:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker build -q -f Dockerfile.arg --build-arg NPM_TOKEN=npm_FAKE_lab_token_0000 \ --target build -t lab-leak:build . ./find-token.sh lab-leak:build npm_FAKE_lab_token_0000
sha256:f2f0229afb7a5c0336cb75939a6d37f3bf12ecea7b5c075673714f4b4bb9b106 history lines: 3 config env: 0 layer blobs: 1 json blobs: 1
$ docker history --no-trunc --format "{{.CreatedBy}}" lab-leak:build | head -4
RUN |1 NPM_TOKEN=npm_FAKE_lab_token_0000 /bin/sh -c test -s .npmrc && echo "dependencies fetched" && mkdir -p /out && echo "app v1" > /out/app.txt # buildkit RUN |1 NPM_TOKEN=npm_FAKE_lab_token_0000 /bin/sh -c printf '//npm.example.test/:_authToken=%s\n' "$NPM_TOKEN" > .npmrc # buildkit WORKDIR /src ARG NPM_TOKEN=npm_FAKE_lab_token_0000
$ mkdir saved && docker save lab-leak:build | tar -x -C saved for b in saved/blobs/sha256/*; do tar -tzf "$b" 2>/dev/null | grep -qx src/.npmrc && tar -xzOf "$b" src/.npmrc done rm -rf saved
//npm.example.test/:_authToken=npm_FAKE_lab_token_0000

The history has three hits. BuildKit records the ARG instruction with its value, and every RUN after it as RUN |1 NPM_TOKEN=... /bin/sh -c .... The |1 means one build argument was in scope, followed by its value. That's how BuildKit keeps the cache key correct. It's also why an ARG is visible to anyone who can read the image. The layer blob hit is the .npmrc file itself. The extract loop pulls it out of the saved image with nothing but tar. A file written into a layer stays in that layer (the reason is in "Layers and the build cache" in Docker for beginners). A later rm in another step wouldn't change this result.

Build records keep the arguments

BuildKit also keeps a record of every build: its status, logs, materials and the request that started it. You read them with docker buildx history:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ ref=$(docker buildx history ls --format '{{.Ref}}\t{{.Name}}' | awk -F'\t' '$2 == "multistage/Dockerfile.arg" {print $1; exit}') echo "record: ${ref:-NOT FOUND}" docker buildx history inspect "${ref##*/}" | sed -n '/^BUILD ARG/,/^$/p'
record: k1hz9708ppojchc6c0rh47g2d BUILD ARG VALUE NPM_TOKEN npm_FAKE_lab_token_0000

The first line is the record the lookup found. BuildKit names a record after the context directory and the Dockerfile. So the awk pattern only matches when the files are in a directory called multistage. If you see NOT FOUND there, adjust the pattern to your directory name. The record holds the argument in clear text, together with the full log from the previous section. Anyone with access to the Docker daemon can read the records, and Docker Desktop shows them in its Builds view. BuildKit keeps them until its history limits drop old records. docker buildx history rm deletes them sooner.

ENV goes further

Dockerfile.env copies a token into the environment, a pattern often used to make a build value available at run time:

Dockerfile.env
# Unsafe on purpose: ENV bakes the value into the image config.
FROM alpine:3.24
ARG API_TOKEN
ENV API_TOKEN=$API_TOKEN
RUN echo "configured"
CMD ["sh", "-c", "echo app started"]
ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker build -q -f Dockerfile.env --build-arg API_TOKEN=npm_FAKE_lab_token_0000 -t lab-leak:env . ./find-token.sh lab-leak:env npm_FAKE_lab_token_0000 docker run --rm lab-leak:env env | grep TOKEN
sha256:c3008b4adb8de80edca8f9cc7203e063d908ee35570a362e12c1b6d482ddf663 history lines: 3 config env: 1 layer blobs: 0 json blobs: 1 API_TOKEN=npm_FAKE_lab_token_0000

Now the value is in the config of the final image (config env: 1) and in its history. It's also in every container started from it, where docker inspect, /proc/1/environ and every child process can read it. "Runtime secrets, done right" covers the run-time side. For the build side, run the checks on their own. --check evaluates the Dockerfile without building and exits non-zero when a rule fires:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker build --check -f Dockerfile.env .
... Check complete, 2 warnings have been found! WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/ Do not use ARG or ENV instructions for sensitive data (ARG "API_TOKEN") Dockerfile.env:3 -------------------- 1 | # Unsafe on purpose: ENV bakes the value into the image config. 2 | FROM alpine:3.24 3 | >>> ARG API_TOKEN 4 | ENV API_TOKEN=$API_TOKEN 5 | RUN echo "configured" -------------------- WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/ Do not use ARG or ENV instructions for sensitive data (ENV "API_TOKEN") Dockerfile.env:4 -------------------- 2 | FROM alpine:3.24 3 | ARG API_TOKEN 4 | >>> ENV API_TOKEN=$API_TOKEN 5 | RUN echo "configured" 6 | CMD ["sh", "-c", "echo app started"] --------------------

SecretsUsedInArgOrEnv is one of BuildKit's build checks. It flags ARG and ENV names that look like credentials (token, password, secret, key and similar). Exit status 1 means you can use --check as a CI gate before the build step. A # check=error=true line at the top of a Dockerfile turns check warnings into build failures for every build of that file. The rule looks at names, never at values, so a credential in an innocently named argument passes it. It's a net for the obvious cases.

Provenance attestations with mode=max

On Docker 29 every BuildKit build attaches a provenance attestation to the image. "Image anatomy: index, manifest, config and layers" in Docker in depth shows where it sits in the index. The attestation travels with the image to every registry. Its detail depends on the mode: min, the default, or max, which adds the full build request. Push the same build both ways to a local registry:3:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker run -d --name lab-registry -p 127.0.0.1:5000:5000 registry:3
6705eac45702cf390ae6fcae19e848c0dd8f49b51016f26e34e867e4f4425499
$ docker build -q -f Dockerfile.arg --build-arg NPM_TOKEN=npm_FAKE_lab_token_0000 \ -t localhost:5000/lab-leak:default --push . docker build -q -f Dockerfile.arg --build-arg NPM_TOKEN=npm_FAKE_lab_token_0000 \ --provenance=mode=max -t localhost:5000/lab-leak:max --push .
sha256:5098d18601eccd15b1bfa34bd7b006cd6d16f4890b5c51a2bc80413391fc99cd sha256:74d4b48877b60b900a28ad3c429973d1035563c6379c8ca52838499b12d22048
$ for t in default max; do n=$(docker buildx imagetools inspect localhost:5000/lab-leak:$t --format '{{json .Provenance}}' | grep -o npm_FAKE_lab_token_0000 | wc -l) echo "provenance of :$t contains the token $n times" done
provenance of :default contains the token 0 times provenance of :max contains the token 4 times
$ docker buildx imagetools inspect localhost:5000/lab-leak:max --format '{{json .Provenance}}' | jq -r '[paths(strings | contains("npm_FAKE_lab_token_0000")) | map(tostring) | join(".")] | .[]'
SLSA.buildDefinition.externalParameters.request.args.build-arg:NPM_TOKEN SLSA.buildDefinition.externalParameters.request.root.request.args.build-arg:NPM_TOKEN SLSA.buildDefinition.internalParameters.buildConfig.llbDefinition.2.op.Op.exec.meta.env.1 SLSA.buildDefinition.internalParameters.buildConfig.llbDefinition.3.op.Op.exec.meta.env.1
$ ./find-token.sh localhost:5000/lab-leak:max npm_FAKE_lab_token_0000
history lines: 0 config env: 0 layer blobs: 0 json blobs: 1

The default attestation has no trace of the token. The max attestation has four. Two are the build arguments of the request (the request and its root). The other two are the environment of the two RUN operations in the build definition. This is the final image, the clean one from the first section, and anyone who can pull it can read its provenance. The last check shows the same attestation inside a local docker save export, so image tarballs carry it too.

Docker's docs state it directly: mode=max exposes the values of build arguments, and secret mounts are never included. Two settings make this common in practice. Supply-chain guidance often recommends mode=max because it records more about the build. And the docker/build-push-action GitHub Action adds mode=max provenance by default when the repository is public. A pipeline that passes a token with --build-arg in a public repository publishes that token with every image. "Pinning, SBOMs, provenance and scanning" covers what provenance is for. The fix is never to turn it down, but to keep secrets out of build arguments.

Exported build cache

CI runners share build cache by exporting it, to a directory or to a registry reference. The default docker driver can export cache as long as the containerd image store is in use. That's the default on fresh Docker 29 installs, this VM included. You'll use a docker-container builder anyway, because that's what most CI setups run, and it also works on hosts still on the legacy store. "Buildx builders, outputs, cache and Bake" in Docker in depth covers drivers and cache export. The builder reads buildkitd.toml, which pulls Docker Hub images through mirror.gcr.io and talks plain HTTP to the lab registry:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker buildx create --name lab-cache --driver docker-container \ --driver-opt network=host --buildkitd-config buildkitd.toml --bootstrap
#1 [internal] booting buildkit #1 pulling image moby/buildkit:buildx-stable-1 #1 pulling image moby/buildkit:buildx-stable-1 2.2s done #1 creating container buildx_buildkit_lab-cache0 #1 creating container buildx_buildkit_lab-cache0 0.7s done #1 DONE 3.0s lab-cache
$ for mode in min max; do docker buildx build --builder lab-cache -q -f Dockerfile.arg \ --build-arg NPM_TOKEN=npm_FAKE_lab_token_0000 \ -o type=cacheonly --cache-to type=local,dest=cache-$mode,mode=$mode . >/dev/null done for d in cache-min cache-max; do hits=0 for b in $d/blobs/sha256/*; do gzip -dcf "$b" | grep -aq npm_FAKE_lab_token_0000 && hits=$((hits + 1)) done echo "$d: $(ls $d/blobs/sha256 | wc -l) blobs, $hits containing the token" done
cache-min: 4 blobs, 0 containing the token cache-max: 7 blobs, 1 containing the token
$ docker buildx build --builder lab-cache -q -f Dockerfile.arg \ --build-arg NPM_TOKEN=npm_FAKE_lab_token_0000 -o type=cacheonly \ --cache-to type=registry,ref=localhost:5000/lab-leak:buildcache,mode=max . >/dev/null for d in $(docker buildx imagetools inspect --raw localhost:5000/lab-leak:buildcache | jq -r '.layers[].digest'); do curl -s http://127.0.0.1:5000/v2/lab-leak/blobs/$d | gzip -dcf | grep -ao '_authToken=[A-Za-z0-9_]*' | sed "s|^|$d\n |" done
sha256:edad32a3a526bf1ebf76ea18c92615f2254821fe32292a8be571085b390dcedf _authToken=npm_FAKE_lab_token_0000

mode=min exports only the layers of the final result: four blobs, none with the token. mode=max exports the intermediate layers of every stage, so the builder's .npmrc layer is in it. The registry export is the same blob under a cache reference. The loop above read it with plain HTTP requests, as anyone with pull access to that repository can. mode=max is what makes exported cache useful for multi-stage builds. So treat a file written in a builder stage as readable by everyone who can pull the cache.

Secret and ssh mounts

Dockerfile.secret does the same work with the two mounts BuildKit provides for credentials. The npm configuration is mounted from a file for one RUN. The ssh mount forwards the client's ssh agent socket, so a git clone over ssh can authenticate while the key stays in the agent. The syntax and options (env=, required, mode, --ssh default) are in "BuildKit builds: stages, cache and mounts" in Docker in depth.

Dockerfile.secret
FROM alpine:3.24 AS build
WORKDIR /src
RUN --mount=type=ssh \
test -S "$SSH_AUTH_SOCK" && echo "ssh agent socket at $SSH_AUTH_SOCK"
RUN --mount=type=secret,id=npmrc,target=/src/.npmrc,required=true \
test -s .npmrc && echo "dependencies fetched" && mkdir -p /out && echo "app v1" > /out/app.txt
FROM alpine:3.24
COPY --from=build /out/app.txt /app/app.txt
CMD ["cat", "/app/app.txt"]

Create the inputs: the npm configuration with the same fake token, and a throwaway ssh key. Then build the builder stage, the worst case from before, with mode=max provenance and a push. Export a mode=max cache from the same Dockerfile too:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ printf '//npm.example.test/:_authToken=%s\n' npm_FAKE_lab_token_0000 > npmrc.txt chmod 600 npmrc.txt ssh-keygen -q -t ed25519 -N "" -C lab-build-key -f lab_ed25519
$ eval "$(ssh-agent -s)" >/dev/null && ssh-add -q lab_ed25519 docker build --progress=plain --no-cache -f Dockerfile.secret --target build \ --secret id=npmrc,src=npmrc.txt --ssh default \ --provenance=mode=max -t localhost:5000/lab-leak:clean --push . docker buildx build --builder lab-cache -q -f Dockerfile.secret \ --secret id=npmrc,src=npmrc.txt --ssh default \ -o type=cacheonly --cache-to type=local,dest=cache-clean,mode=max . ssh-agent -k >/dev/null
#0 building with "default" instance using docker driver ... #6 [build 3/4] RUN --mount=type=ssh test -S "$SSH_AUTH_SOCK" && echo "ssh agent socket at $SSH_AUTH_SOCK" #6 0.143 ssh agent socket at /run/buildkit/ssh_agent.0 #6 DONE 0.2s #7 [build 4/4] RUN --mount=type=secret,id=npmrc,target=/src/.npmrc,required=true test -s .npmrc && echo "dependencies fetched" && mkdir -p /out && echo "app v1" > /out/app.txt #7 0.097 dependencies fetched #7 DONE 0.1s ...

The build log shows the RUN lines with their mount flags and no value. The ssh step saw only a socket path inside the build container. Now search every place that leaked before, for the token and for a line of the private key file:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ key=$(sed -n 3p lab_ed25519) ref=$(docker buildx history ls --format '{{.Ref}}\t{{.Name}}' | awk -F'\t' '$2 == "multistage/Dockerfile.secret (build)" {print $1; exit}') echo "record: ${ref:-NOT FOUND}" for s in npm_FAKE_lab_token_0000 "$key"; do echo "== ${s:0:12}..." ./find-token.sh localhost:5000/lab-leak:clean "$s" echo "provenance: $(docker buildx imagetools inspect localhost:5000/lab-leak:clean --format '{{json .Provenance}}' | grep -o -- "$s" | wc -l)" echo "cache blobs: $(for b in cache-clean/blobs/sha256/*; do gzip -dcf "$b" | grep -a -- "$s"; done | wc -l)" echo "build record: $(docker buildx history inspect "${ref##*/}" | grep -c -- "$s")" echo "build log: $(docker buildx history logs "${ref##*/}" 2>&1 | grep -c -- "$s")" done
record: t6jcn7wvhun79xyd9pbibzl4l == npm_FAKE_lab... history lines: 0 config env: 0 layer blobs: 0 json blobs: 0 provenance: 0 cache blobs: 0 build record: 0 build log: 0 == QyNTUxOQAAAC... history lines: 0 config env: 0 layer blobs: 0 json blobs: 0 provenance: 0 cache blobs: 0 build record: 0 build log: 0
$ docker buildx imagetools inspect localhost:5000/lab-leak:clean --format '{{json .Provenance}}' | jq -c '.SLSA.buildDefinition.externalParameters.request | {args, secrets, ssh}'
{"args":{"no-cache":"","target":"build"},"secrets":[{"id":"npmrc"}],"ssh":[{"id":"default","optional":true}]}

Zero everywhere: history, image config, layers, the attestation, the exported cache, the build record and its log. The record: line confirms that the last two counts come from the record of this build, not from an empty lookup. The second search string is the third line of the private key file, which holds key material. The max provenance records that a secret called npmrc and an ssh agent called default were used. That's useful for auditing, and it's all you get. The secret file is mounted on a tmpfs for the duration of the RUN. It isn't part of the layer that step produces, so no cache mode can export it.

One limit remains. A mount keeps the value out of the image, but the command that runs inside the step can still put it somewhere. RUN --mount=type=secret,id=npmrc,target=/src/.npmrc cp .npmrc /tmp/ writes it into the layer. A step that echoes the value prints it into the build log. Mounts protect what BuildKit records, not what your commands do.

Where each value ends up

Every cell is a result from this lab, and a dash is a case the lab didn't test. An ENV value is worse than all of these, since it sits in the config of the final image. If a real token has already gone through one of these channels, rotate it first. Deleting a tag, a cache reference or a build record afterwards limits further spread. It doesn't undo copies that runners, mirrors and pull caches already hold.

Clean up

Remove the builder (which deletes its cache and records), the registry, the images and this lesson's build records from the default builder:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker buildx rm lab-cache docker rm -f -v lab-registry docker image rm lab-leak:arg lab-leak:build lab-leak:env \ localhost:5000/lab-leak:default localhost:5000/lab-leak:max localhost:5000/lab-leak:clean docker buildx history ls --format '{{.Ref}}\t{{.Name}}' | awk -F'\t' '$2 ~ /^multistage\/Dockerfile\.(arg|env|secret)/ {sub(".*/", "", $1); print $1}' | xargs -r docker buildx history rm rm -rf cache-min cache-max cache-clean npmrc.txt lab_ed25519 lab_ed25519.pub
lab-cache removed lab-registry Untagged: lab-leak:arg Deleted: sha256:e5d794be8b44b1b5e885af7930e666bcabb1411c04f6615198e8e9d3360559a1 Untagged: lab-leak:build Deleted: sha256:f2f0229afb7a5c0336cb75939a6d37f3bf12ecea7b5c075673714f4b4bb9b106 Untagged: lab-leak:env Deleted: sha256:c3008b4adb8de80edca8f9cc7203e063d908ee35570a362e12c1b6d482ddf663 Untagged: localhost:5000/lab-leak:default Deleted: sha256:5098d18601eccd15b1bfa34bd7b006cd6d16f4890b5c51a2bc80413391fc99cd Untagged: localhost:5000/lab-leak:max Deleted: sha256:74d4b48877b60b900a28ad3c429973d1035563c6379c8ca52838499b12d22048 Untagged: localhost:5000/lab-leak:clean Deleted: sha256:990b3cd657378ef1049ff7d23734e269a1a27852e32f5a53201f7cbc5b9c9818

Try this

On the main lab VM, copy Dockerfile.arg to a new file and rename NPM_TOKEN to something bland, such as BUILD_REF, everywhere in it. Run docker build --check on the original and then on the copy. Predict both exit codes before you look: the rule reads names, never values, so the fake token is just as exposed in the copy. Compare with the SecretsUsedInArgOrEnv warning that arg-build printed.

Takeaway

Pass build credentials only through --mount=type=secret or --mount=type=ssh. The fake token you sent with --build-arg showed up in the build log and in 3 history lines of the builder stage. It was also in the build record, and 4 times in the mode=max attestation, while the mount build counted zero everywhere. The trap in real pipelines is that the shipped image scans clean. So nobody checks the CI log, the exported cache or the provenance that went out with it.

Quick check
01A multi-stage Dockerfile passes NPM_TOKEN with --build-arg and uses it only in the builder stage. docker history --no-trunc of the pushed final image shows no token. CI pushes with --provenance=mode=max. Who can read the token?
Incorrect — The final image is clean, but its mode=max attestation records the build arguments. The lab found the token 4 times in it.
Correct — mode=max provenance includes the build request arguments and the environment of RUN steps, and it is pushed with the image.
Incorrect — Build records and logs are on the build host, but the attestation went to the registry with the image.
Incorrect — Rebuilding with --target exposes the history of the builder stage, but the pushed attestation already exposes the value without any rebuild.
02A team exports build cache to a registry with --cache-to type=registry,ref=...,mode=max. The builder stage writes .npmrc with a token into a layer; the final stage copies only the binary. What does a pull of the cache reference expose?
Incorrect — Cache blobs are ordinary compressed tar layers; the lab read one with curl and gzip.
Incorrect — mode=max exports the layers themselves, not just keys.
Incorrect — mode=min exports only the final result layers; the lab found the token in none of them.
Correct — mode=max exports intermediate layers of every stage, so the file is in the cache.
03You replace the ARG with RUN --mount=type=secret,id=npmrc,target=/src/.npmrc npm ci. Which change would put the token back into the image?
Correct — The mount keeps the file out of the layer, but a copy made by the command is an ordinary file in the layer.
Incorrect — Provenance records only the secret id. The lab found no trace of the value with mode=max.
Incorrect — The secret is not part of the layer the step produces, so the cache has nothing to export; the lab checked.
Incorrect — The builder image had no trace of the token in history, config or layers.

Related