Skip to content

Commit c8867db

Browse files
committed
fix(secrets): name the provenance, not just docker history
The skill told readers that `ARG` secrets persist in `docker history` and to use a secret mount. For a multi-stage build the first half is false in the way that matters: a credential passed into a builder stage never reaches the shipped layers, so `docker history` is clean — and the value is public anyway, because buildx records every build argument verbatim in the SLSA provenance attestation it attaches to the pushed image. Following the old line means running the one check that cannot see the leak. A public GHCR package was found carrying a live GitLab token in the attestation of 1461 published versions this way. The reference adds the inspect command, the fix, and the verification trap: grep for the credential pattern, never for the argument name — the attestation embeds the push's commit messages and RUN command lines, so the name matches your own commit text and reports a leak that is not there. SKILL.md is at its 500-word cap, so the added line and reference row are paid for by tightening prose the adjacent code blocks already state. Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
1 parent cff7fcc commit c8867db

2 files changed

Lines changed: 72 additions & 9 deletions

File tree

skills/docker-development/SKILL.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,12 @@ allowed-tools:
1818

1919
# Docker Development
2020

21-
Patterns for building, testing, and deploying Docker containers.
22-
2321
## Core Principles
2422

2523
1. **Minimal** -- Alpine/distroless, multi-stage
2624
2. **Secure** -- Non-root USER, no layer secrets, pin versions
27-
3. **Testable** -- CI-verifiable: entrypoint bypass, DNS mocking
28-
4. **Cache-efficient** -- deps first, clean in same layer
25+
3. **Testable** -- entrypoint bypass, DNS mocking
26+
4. **Cache-efficient** -- deps first, clean in-layer
2927

3028
## Quick Reference
3129

@@ -78,15 +76,16 @@ RUN npm ci
7876
COPY . .
7977
```
8078

81-
Manifests before source keeps install layers cached on source-only changes.
79+
Manifests before source keeps install layers cached.
8280

8381
### BuildKit Secrets
8482

8583
```dockerfile
8684
RUN --mount=type=secret,id=ssh_key,dst=/root/.ssh/id_rsa git clone git@github.com:org/repo.git
8785
```
8886

89-
`ENV`/`ARG`/`COPY` secrets persist in `docker history`. Use `--mount=type=secret`.
87+
`ARG` leaks via `docker history` **and** the pushed image's SLSA provenance --
88+
`references/build-secret-leaks.md`
9089

9190
### Docker Bake (Multi-Platform)
9291

@@ -115,7 +114,7 @@ target "app" {
115114
1. **Bypass entrypoint**: `docker run --rm --entrypoint php myimage -v`
116115
2. **Mock upstream DNS**: `docker run --rm --add-host backend:127.0.0.1 nginx-image nginx -t`
117116
3. **Compose validation**: `cp .env.example .env` before `docker compose config`
118-
4. **Secret scanning**: Exclude `.env.example`, README, docs from scanners
117+
4. **Secret scanning**: exclude `.env.example`, README, docs
119118
5. **Root-owned artifacts**: root-owned bind-mount dirs (`EACCES`) -- `references/bind-mount-ownership.md`
120119

121120
## .dockerignore
@@ -125,12 +124,13 @@ Exclude: `.git`, `node_modules`/`vendor`, `.env*`, `*.pem`, `*.key`
125124
## Compose Essentials
126125

127126
- startup ordering: `depends_on.condition: service_healthy` + `healthcheck` `start_period`
128-
- `networks.internal: true` isolates databases from external access
129-
- `profiles: [debug]`: services start only with `--profile debug`
127+
- `networks.internal: true` isolates databases
128+
- `profiles: [debug]`: start only with `--profile debug`
130129

131130
## References
132131

133132
- `references/ci-testing.md` -- CI testing patterns for Docker images
134133
- `references/dind-testing-patterns.md` -- Docker-in-Docker testing patterns
135134
- `references/bind-mount-ownership.md` -- root-owned bind-mount artifacts
136135
- `references/gpg-verification.md` -- gpgv patterns; stale keybox locks
136+
- `references/build-secret-leaks.md` -- `ARG` lands in SLSA provenance
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Where a build credential actually leaks
2+
3+
`docker history` is the check everyone runs, and for a multi-stage build it is
4+
the wrong one. A credential passed as `ARG` into a builder stage never reaches
5+
the shipped layers — that stage is not in the final image — so the history is
6+
clean while the credential is public anyway.
7+
8+
buildx attaches an SLSA provenance attestation to every pushed image and records
9+
the build arguments in it **verbatim**:
10+
11+
```bash
12+
docker buildx imagetools inspect <ref> --format '{{json .Provenance}}' \
13+
| jq -r '.[].SLSA.buildDefinition.externalParameters.request.args | keys[]'
14+
# build-arg:COMPOSER_AUTH <- the value sits right there, in cleartext
15+
```
16+
17+
Measured case (2026-08-12): a public GHCR package carried a working GitLab
18+
`glpat-` token in the attestation of **1461** published versions. The `ARG` was
19+
in a `composer-builder` stage that is never shipped.
20+
21+
## Verify it, with a check that can fail
22+
23+
Grep for the **credential pattern**, never for the argument name. The
24+
attestation embeds the push's commit messages and the `RUN` command lines, so
25+
the name matches your own commit text and reports a leak that is not there —
26+
that false positive cost a round of "still broken" in the session that found
27+
this.
28+
29+
```bash
30+
for ref in "$OLD_TAG" "$NEW_TAG"; do
31+
n=$(docker buildx imagetools inspect "$ref" --format '{{json .Provenance}}' \
32+
| grep -oE 'glpat-[A-Za-z0-9_-]{10,}|ghp_[A-Za-z0-9]{20,}' | sort -u | wc -l)
33+
echo "$ref: $n credential values"
34+
done
35+
```
36+
37+
A pre-fix tag returning `1` and a post-fix tag returning `0` is the proof. A
38+
single post-fix `0` on its own proves nothing about the check.
39+
40+
## The fix
41+
42+
```dockerfile
43+
RUN --mount=type=secret,id=composer_auth \
44+
COMPOSER_AUTH="$(cat /run/secrets/composer_auth 2>/dev/null || true)" \
45+
composer install --no-dev
46+
```
47+
48+
```hcl
49+
target "app" {
50+
secret = ["type=env,id=composer_auth,env=COMPOSER_AUTH"]
51+
}
52+
```
53+
54+
`type=env` reads the variable the CI already exports, so the calling workflow
55+
usually needs no change at all. Tolerate a missing secret (`|| true`): forks and
56+
local builds have no credential and should still resolve public dependencies
57+
rather than fail.
58+
59+
## After a leak
60+
61+
Rotation is the only remedy that acts on what is already published — the
62+
attestations of existing versions keep their copy of the value forever, or until
63+
someone deletes those package versions. Rotate first, fix the build second.

0 commit comments

Comments
 (0)