Docker & ContainersXIV Β· SecretsLeaked secrets
Preventing the leak β build context hygiene and CI secret scanning
What you'll learn
- Explain what the build context is and what it carries
- Write a .dockerignore that denies by default
- Verify what an image actually contains rather than what you intended
- Place secret scanning where it blocks rather than where it reports
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
The previous lesson dealt with a secret that is already in an image.
Almost every one of those arrived through the same door: COPY . .
in a Dockerfile, and a working directory containing rather more than
the author had in mind.
What the build context is
docker build . does not read your directory the way a compiler
does. It packages the directory β the context β and hands it to the
builder, which is a separate process and possibly a separate machine.
The consequence people miss: the contents of your working directory
are the raw material for the build, and the Dockerfile decides what
survives into the image. If the Dockerfile says COPY . /app, the
answer is βall of itβ.
$ du -sh .[!.]* * 2>/dev/null | sort -h | tail -88.0K .env.production
12K Dockerfile
1.1M src
14M .git
196M node_modulesIllustrative output
.env.production and .git are the two that matter. The first is a
file of credentials by definition. The second is worse than it looks:
a git repository contains every version of every file that was ever
committed, so a secret committed in March and removed in April is
still in .git in August. Copy .git into an image and you have
shipped the entire history, including the removals somebody thought
had fixed things.
.dockerignore, written the safe way
Most .dockerignore files are a denylist that grew by incident:
# The pattern that keeps failing
.git
.env
node_modules
This is wrong in the way all denylists are wrong. It protects against
the three things somebody has already been burnt by, and says nothing
about credentials.json, .env.local, id_rsa, terraform.tfstate
or kubeconfig β none of which existed in the repository when the
list was written.
Deny everything and admit what you need:
# Ignore the entire context...
*
# ...then admit exactly what the build requires.
!src/
!public/
!package.json
!package-lock.json
!tsconfig.json
Now a new file in the repository root is excluded by default, and including it is a deliberate, reviewable change to this file. That inversion is the whole point: the failure mode becomes βthe build breaks because I forgot to allow a fileβ, which you find in thirty seconds, instead of βa credential shippedβ, which you find in six weeks.
Verify, do not assume
A .dockerignore you have not tested is a hypothesis. Two commands
settle it.
What the build actually received:
$ docker build --no-cache --progress=plain -t example.com/api:test . 2>&1 | grep -E 'transferring context'#1 [internal] load build context
#1 transferring context: 1.18MB 0.1s doneIllustrative output
1.18 MB against a directory containing 210 MB is the .dockerignore
doing its job. A number close to the full directory size means it is
not being applied β usually because it is in the wrong place relative
to the context root.
What the image actually contains:
$ docker run --rm --entrypoint sh example.com/api:test -c 'ls -A /app; find / -xdev -name .git -maxdepth 4 2>/dev/null'node_modules
package.json
package-lock.json
srcIllustrative output
No .git, no .env. Run this once per image, not once per project β
a .dockerignore that was correct last year describes last yearβs
repository.
Making the pipeline refuse
Hygiene depends on attention, and attention is not a control. The control is a scanner that fails a build.
There are three useful places to put one, and they catch different things:
| Stage | Catches | Cost of a catch |
|---|---|---|
| Pre-commit hook | The credential before it is committed | Seconds. Nothing to clean up. |
| CI, on the repository | A credential in the branch or its history | Minutes. Rewrite or rotate. |
| CI, on the built image | Anything that reached a layer, from any source | An hour. Rotate and rebuild. |
You want all three, and you want them in that order, because the cost column doubles at every step.
On the repository
gitleaks and trufflehog are the two common choices. Both scan
working trees and git history.
#!/usr/bin/env bash
# ci-secret-scan.sh - fail the pipeline on a detected credential.
set -euo pipefail
# --redact keeps the finding out of the CI log, which is itself a
# place secrets leak. A non-zero exit fails the job.
gitleaks detect --source . --redact --exit-code 1
On the image
Repository scanning cannot see a credential that arrived from a CI variable, a cached layer, or a base image. Scan the artefact you are about to publish:
#!/usr/bin/env bash
set -euo pipefail
IMAGE="example.com/api:${CI_COMMIT_SHA}"
docker build -t "$IMAGE" .
trivy image --scanners secret --exit-code 1 "$IMAGE"
docker push "$IMAGE"
The ordering matters: scan between build and push. A scanner that
runs after docker push is a reporting tool, not a gate β by the
time it fires, the layer is in the registry and the previous lessonβs
remediation applies.
The leaks a scanner will not catch
Two more that are worth an explicit check:
- Image labels.
LABELvalues are stored in the image config exactly likeENV. A build that stamps a label with a CI variable can stamp a token into every image it produces. - Compose
.envinterpolation. Compose reads.envfrom the project directory to substitute${VAR}in the Compose file. That file is not sent to any container, but it is a credential file in the repository root, and it is the single most commonly committed secret in Docker projects. It belongs in.gitignoreand.dockerignore.
- Invert
.dockerignoreto deny by default and admit explicitly. - Confirm the context size the builder reports is what you expect.
- Inspect a built image for
.git,.envand stray key files. - Add a pre-commit secret scanner so the cheap catch happens first.
- Add an image scanner between build and push, with a non-zero exit failing the job.
- Ban
set -xand build args for credentials; use secret mounts.
Knowledge check
Knowledge check Β· 4 questions
Q1. Why is copying `.git` into an image particularly dangerous?
Q2. What makes a `*` plus `!` allowlist in `.dockerignore` safer than listing files to exclude?
Q3. Which leaks would a repository secret scanner fail to catch? Select all that apply.
Q4. An image secret scanner that runs after `docker push` is a reporting tool rather than a gate.
Passing score: 75%. Answers are checked in this browser.
Where next
That completes the secrets part: why env vars fail, build and runtime delivery, external managers, rotation, forensics on a leak, and prevention. The supply-chain part picks up the related question of trusting what is inside an image you did not build.