You built and pushed an image, and now you suspect an AWS key, a registry token, or a .env file is sitting inside one of its layers — but you have no repeatable way to confirm it or to stop the next build from doing the same thing. This how-to belongs to secret scanning and leak prevention, part of the wider environment sync, secrets and CI parity baseline. The reason this is hard to catch by eye is that a secret can enter a layer through several unrelated paths — a --build-arg, an ENV line, a COPY . . that swept up an untracked .env, or a cached credential file written by a package manager — and each path hides the value in a different place. This page shows how to point trivy and gitleaks at an image so detection is deterministic, how to read what they report, and how to fix the leaky build args that most often cause a real disclosure. It pairs closely with the mechanics covered in stopping env variable leakage in multi-stage builds, which explains why the value persists; here the focus is detecting it after the fact and gating it in CI.

Diagnostic

Two scanners cover complementary ground. trivy image walks every layer blob and the image config, applying a large ruleset of secret patterns; gitleaks applies regex and entropy rules and is stronger on high-entropy tokens and custom formats. Run both against the suspect image. The commands below assume an image tagged app:suspect that was built with a token passed as a build arg.

#!/usr/bin/env bash
set -euo pipefail

IMAGE="app:suspect"

# 1) trivy scans layers + config for secret patterns, no unpacking needed
trivy image --scanners secret --severity HIGH,CRITICAL "$IMAGE"

# 2) gitleaks needs a filesystem; export the image rootfs first
CID="$(docker create "$IMAGE")"
rm -rf /tmp/rootfs && mkdir -p /tmp/rootfs
docker export "$CID" | tar -x -C /tmp/rootfs
docker rm "$CID" >/dev/null
gitleaks dir /tmp/rootfs --no-banner --redact

Expected BAD output — trivy reports the pattern it matched and the exact layer, and gitleaks reports the rule and file:

app:suspect (debian 12.5)
Total: 2 (HIGH: 1, CRITICAL: 1)

CRITICAL: AWS Access Key ID
 Match:    AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
 Layer:    sha256:9c1b6dd… (ADD .env /app/.env)

HIGH: npm auth token
 Match:    //registry.npmjs.org/:_authToken=npm_9f3c…
 Layer:    sha256:41ab77… (RUN npm config set …)

    Finding:     AWS_ACCESS_KEY_ID=REDACTED
    File:        app/.env
    Rule:        aws-access-token

Two independent findings from two independent tools is a confirmed disclosure, not a false positive. The .env came in through a COPY/ADD that trivy attributes to a specific layer, and the npm token is embedded in a RUN command string. Because docker export flattens the container filesystem, gitleaks sees the final merged rootfs — useful for files that survive to the last layer, but it will not see a file that an intermediate layer added and a later layer deleted. Trivy's per-layer scan does catch that case, which is exactly why you run both.

Read the two report shapes with their differences in mind. Trivy classifies a match by rule name (AWS Access Key ID, npm auth token) and prints the layer digest plus the instruction that created it, so the Layer: line tells you exactly which Dockerfile step to change. Gitleaks reports the Rule: that fired, the File: path within the exported rootfs, and — when you drop --redact — the offending line, which is invaluable when the finding is a bespoke internal token that matched an entropy rule rather than a named pattern. When the two tools disagree on the count of findings, that is expected and informative: trivy may report three layer-level hits for a single credential that appears in an ARG, a RUN, and a copied file, while gitleaks reports it once from the flattened view. Deduplicate by the credential value, not by the raw finding count, before deciding what to rotate.

How a secret enters a layer and how scanners find it A build input flows into an image layer, then trivy and gitleaks read it back out during a scan. Leak In, Scanner Out build input arg / COPY / RUN image layer blob + config trivy per-layer scan gitleaks rootfs scan Two readers, one truth: agreement across tools confirms a real disclosure.
The same layer that stores the secret is exactly what the scanners read back — detection is deterministic.

Root cause

Secret scanners find embedded credentials because Docker image layers are content-addressed archives that store everything a build instruction produced, and neither the layer blob nor the image config was ever designed to hide values. A --build-arg is recorded in the image config's history; an ENV line lives in both the config and every container's process environment; a RUN records the literal shell string it executed; and a COPY . . or ADD physically copies file bytes — including an untracked .env — into the layer's tarball. Scanners simply read those artifacts the same way a docker pull does, so anything a scanner can see, an attacker who pulls the image can see too. The difference between trivy and gitleaks is where they look: trivy inspects each layer blob and the config independently, so it catches a secret in a layer that a later instruction deleted, while gitleaks scans the flattened rootfs and excels at high-entropy or custom token shapes that a pattern list might miss. Neither is a superset of the other, and a leak that only one tool reports is still a real leak.

The single most common cause of a .env landing in an image is a broad COPY . . with no .dockerignore to exclude it. The build context is the directory you point docker build at, and by default every file in it — including untracked local secrets that git never tracked and therefore never warned you about — is eligible to be copied. A developer who keeps a working .env beside the Dockerfile for local runs will silently ship it the first time a build copies the whole tree. Build args are the second cause: they feel transient because they are supplied on the command line, but Docker records each one in the image config's history array, so the value is one docker history or docker inspect away from any consumer of the image. Recognising which of these two mechanisms produced a finding tells you whether the fix is a .dockerignore entry or a secret mount.

Trivy versus gitleaks coverage Comparison of what a per-layer scanner sees versus a flattened-rootfs scanner across four rows. trivy vs gitleaks trivy (per layer) reads each layer blob reads image config sees deleted-then-gone files names the leaking layer broad pattern set gitleaks (rootfs) scans flattened files strong on entropy custom regex rules misses deleted layers tunable per repo
Run both: per-layer coverage and entropy coverage catch different leaks, and neither replaces the other.

Resolution

  1. Install both scanners deterministically. Pin versions so a CI upgrade never silently changes what is or is not flagged.
#!/usr/bin/env bash
set -euo pipefail

TRIVY_VERSION="0.53.0"
GITLEAKS_VERSION="8.18.4"

curl -fsSL "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
  | tar -xz -C /usr/local/bin trivy
curl -fsSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
  | tar -xz -C /usr/local/bin gitleaks

trivy --version
gitleaks version
  1. Scan the image and fail on any finding. Trivy exits non-zero with --exit-code 1 when it matches a secret, which is what makes it usable as a gate rather than a report you have to read manually.
#!/usr/bin/env bash
set -euo pipefail

IMAGE="${1:?usage: scan.sh <image:tag>}"

# Layer + config scan; non-zero exit on any HIGH/CRITICAL secret
trivy image \
  --scanners secret \
  --severity HIGH,CRITICAL \
  --exit-code 1 \
  --format table \
  "$IMAGE"

# Flattened-rootfs scan with gitleaks
CID="$(docker create "$IMAGE")"
trap 'docker rm "$CID" >/dev/null 2>&1 || true' EXIT
rm -rf /tmp/rootfs && mkdir -p /tmp/rootfs
docker export "$CID" | tar -x -C /tmp/rootfs
gitleaks dir /tmp/rootfs --no-banner --redact --exit-code 1
echo "scan clean: $IMAGE"
  1. Fix the leaky build arg that caused the finding. The AWS key arrived because .env was copied into the build context and then into the image; the npm token arrived as a build arg. Replace both with a .dockerignore entry and a BuildKit secret mount so the value is never written to a layer.
# syntax=docker/dockerfile:1.7
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npm_token \
    npm config set //registry.npmjs.org/:_authToken="$(cat /run/secrets/npm_token)" && \
    npm ci && \
    npm config delete //registry.npmjs.org/:_authToken

FROM node:20-slim AS runtime
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]
# .dockerignore — keep credentials and history out of the build context
.env
.env.*
.git
**/*.pem
**/id_rsa
npm-debug.log
  1. Rebuild with the secret supplied out-of-band and re-scan. The token is piped through /dev/stdin, so it never lands in shell history or a temp file, and the .dockerignore stops COPY . . from sweeping up .env.
#!/usr/bin/env bash
set -euo pipefail
export DOCKER_BUILDKIT=1

printf '%s' "$NPM_TOKEN" | docker build \
  --secret id=npm_token,src=/dev/stdin \
  -t app:clean .

./scan.sh app:clean
Scan-fix-verify remediation loop Four ordered stages: scan finds the secret, fix the build input, rebuild clean, re-scan to confirm. Scan, Fix, Verify 1 — scan flags secret + layer 2 — dockerignore + secret mount 3 — rebuild without the arg 4 — re-scan returns clean
Every fix ends with a re-scan; a change is only done when the scanner that flagged it goes quiet.

Expected output

After the rebuild, both tools return clean and both exit zero, so the CI gate passes:

$ ./scan.sh app:clean
app:clean (debian 12.5)
Total: 0 (HIGH: 0, CRITICAL: 0)

    no leaks found
scan clean: app:clean

Trivy prints Total: 0 and gitleaks prints no leaks found. The scan clean line only prints because both commands exited zero under set -e — if either had matched, the script would have aborted before reaching it. That is the property you want in CI: a green pipeline means every scanner agreed the image is clean, and there is no report a human has to remember to open.

Prevention

  1. Gate every image build in CI. Wire the scan into the pipeline so a leak fails the job before the tag is ever pushed. This Compose-based CI service runs the scan against a freshly built local image:
services:
  scan:
    image: aquasec/trivy:0.53.0
    command:
      - image
      - --scanners
      - secret
      - --severity
      - HIGH,CRITICAL
      - --exit-code
      - "1"
      - app:ci
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
  1. Stop leaks before they are committed. A gitleaks pre-commit hook catches a credential in source before it can reach the build context at all — cheaper than catching it in the image.
#!/usr/bin/env bash
set -euo pipefail
# .git/hooks/pre-commit (or a pre-commit framework stage)
if ! gitleaks git --staged --no-banner --redact; then
  echo "Refusing commit: gitleaks found a secret in staged changes"
  exit 1
fi
  1. Scan pushed tags on a schedule. A leak that predates the gate still sits in the registry, and images built outside the pipeline never hit the CI check. A nightly job that pulls each published tag and runs the same scan.sh catches regressions and out-of-band builds. Fold this into the consolidated checks described under running one parity validation pass so credential hygiene is verified alongside your other environment invariants rather than as a separate step someone can forget.
Findings by scan stage Bar chart comparing how many secret findings each gate catches across pre-commit, CI, and scheduled scans. Findings Caught by Gate no gate 10 pre-commit 6 + CI + nightly 10
Illustrative counts: pre-commit stops the common cases, but only CI plus scheduled scans reach every leak path.

Platform caveats

macOS (Docker Desktop): docker export streams through the VM, so exporting a multi-gigabyte image to /tmp/rootfs can be slow and fills the Docker Desktop disk image; prefer trivy image (which reads layers directly from the daemon) and reserve the gitleaks rootfs pass for images you have reason to suspect. WSL2: run the scan from the Linux filesystem, not a /mnt/c/... path — exporting the rootfs onto a Windows drive relaxes file permissions and is markedly slower, and gitleaks scanning a Windows-mounted tree can miss files whose case-folding differs from Linux. Apple Silicon (ARM64): scan the exact architecture you publish. Pin --platform linux/amd64 when your registry image targets amd64 runners, or trivy scans a locally built ARM64 image and can pass while the real amd64 artifact still carries the secret.

Rollback

If a leaked image was already pushed, treat the credential as compromised and rotate it before deleting anything — removing the tag does not un-distribute layers that registry mirrors, pull-through caches, and developer laptops have already fetched. Rotate at the source, then remove local and remote copies and rebuild clean.

#!/usr/bin/env bash
set -euo pipefail
# 1) rotate the credential at its source (cloud console, registry, secret store)
# 2) remove local copies
docker rmi app:suspect || true
# 3) delete the pushed tag (example for a v2 registry)
curl -fsS -X DELETE "https://$REGISTRY/v2/app/manifests/$DIGEST"

Layer metadata cannot be edited in place, so there is no way to scrub the value from an existing digest — the clean state is a fresh build from a Dockerfile that never received the secret. If the leaked tag was ever deployed, audit the credential's own access logs for unexpected use before and after rotation.

Frequently Asked Questions

Why run both trivy and gitleaks instead of picking one?

They cover different failure modes. trivy image scans each layer blob and the image config separately, so it catches a secret that an intermediate layer added and a later layer deleted, and it names the leaking layer. gitleaks scans the flattened rootfs and is stronger on high-entropy tokens and custom regex rules you define per repository. Neither is a superset of the other, so a leak only one tool reports is still a real leak.

Does gitleaks see a secret that was deleted in a later layer?

No. Because the rootfs pass runs against docker export, which flattens the container filesystem, gitleaks only sees files that survive to the final layer. A credential added by one layer and removed by a later one is gone from the flattened view but still recoverable from the earlier layer blob — which is precisely the case trivy image catches with its per-layer scan.

How do I stop the scanner from flagging a known test fixture?

Use each tool's allowlist rather than lowering severity globally. gitleaks reads a .gitleaks.toml with an [allowlist] section keyed on path or regex, and trivy accepts a --secret-config file that disables specific rules or paths. Scope the exception as narrowly as possible — an allowlisted path is a path the gate no longer protects, so pin it to the fixture file, never to a whole directory of real config.

Why did the scan pass locally but the pushed image still leaked?

Almost always an architecture mismatch or an out-of-band build. If you build ARM64 locally but publish amd64, trivy scanned a different image; pin --platform to the published architecture. And an image built on a laptop outside CI never hit the pipeline gate, so add a scheduled scan that pulls every published tag and runs the same scan script.