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: f98db67ee6e0bc72c68b097b4ac5c916ced6fe279633aa732ff00c1bc8395c68

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. This lesson finds all of them with a fake token, then shows 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; this lesson assumes that syntax and asks a different question: once a value enters a build, where does it go? The token npm_FAKE_lab_token_0000 is not 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 found in many repositories. The builder stage takes the registry token as an ARG, writes an .npmrc, 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.22 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.22
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: the history (docker history --no-trunc), the environment in the image config, 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.8.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.4s #7 [build 4/4] RUN test -s .npmrc && echo "dependencies fetched" && mkdir -p /out && echo "app v1" > /out/app.txt #7 0.147 dependencies fetched #7 DONE 0.2s #8 [stage-1 2/2] COPY --from=build /out/app.txt /app/app.txt #8 DONE 0.2s ...
$ ./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 the 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 would:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.8.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:aefe571b7b64ba578db7754a607f5a499456dba01387a80f82686f40a77a7df4 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 is how BuildKit keeps the cache key correct, and it is 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), and a later rm in another step would not 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. docker buildx history reads them:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.8.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: 22hms4ra04wug7zzlt35l8z55 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; with 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.22
ARG API_TOKEN
ENV API_TOKEN=$API_TOKEN
RUN echo "configured"
CMD ["sh", "-c", "echo app started"]
ubuntu@secopslog-docker:~/lab/multistage · Docker 29.8.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:c6ecac6a7eaf0c621eb896d9c229ea4c7a2d9e8788cdd771c000006064125b32 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), in its history and 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.8.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.22 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.22 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 makes --check usable as a CI gate before the build step, and 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 is 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.8.2
$ docker run -d --name lab-registry -p 127.0.0.1:5000:5000 registry:3
6d126bb24861068a446dc639eac2040b77c32717bedacf7cc10aeda3121332fc
$ 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:3dd7644d90eb4842e173c1225af18a491f2f408ac2e9758997ac54a23babbb93 sha256:ce16d5ee31a55a065bf4f14578d52d4af5f6e4b172d61c2210dbfb943ae00def
$ 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: the build arguments of the request (twice, the request and its root) and 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 documentation states 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, which is the default on fresh Docker 29 installs, this VM included. This section uses a docker-container builder anyway, because that is 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.8.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.6s done #1 creating container buildx_buildkit_lab-cache0 #1 creating container buildx_buildkit_lab-cache0 1.0s done #1 DONE 3.6s 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:5a68ba78250339f8d6853aa15945824a22700720addc41557c1bbca0f9eb8b5a _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, and 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.22 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.22
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, and export a mode=max cache from the same Dockerfile:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.8.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.456 ssh agent socket at /run/buildkit/ssh_agent.0 #6 DONE 0.5s #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.953 dependencies fetched #7 DONE 1.0s ...

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.8.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: v0pamo7138j559p6pqtl9o1v8 == 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, which is useful for auditing, and nothing else. The secret file is mounted on a tmpfs for the duration of the RUN and is not 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, and 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; a dash is a case the lab did not 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 but does not 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.8.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:6324770042cdb14da959e200eb0e0c78ffea10beb1e7faa7cbcf394a95ece3b2 Untagged: lab-leak:build Deleted: sha256:aefe571b7b64ba578db7754a607f5a499456dba01387a80f82686f40a77a7df4 Untagged: lab-leak:env Deleted: sha256:c6ecac6a7eaf0c621eb896d9c229ea4c7a2d9e8788cdd771c000006064125b32 Untagged: localhost:5000/lab-leak:default Deleted: sha256:3dd7644d90eb4842e173c1225af18a491f2f408ac2e9758997ac54a23babbb93 Untagged: localhost:5000/lab-leak:max Deleted: sha256:ce16d5ee31a55a065bf4f14578d52d4af5f6e4b172d61c2210dbfb943ae00def Untagged: localhost:5000/lab-leak:clean Deleted: sha256:75fd7669d2da9d687c95cc1f8cc2e01542a36822062b10a37f13f92385560ab6
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.

Try this

Work through “Clean up” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from build-time secrets and how images leak them, keep “Clean up”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related