Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# dockerfile-pin

A CLI tool that adds `@sha256:<digest>` to `FROM` lines in Dockerfiles, `image` fields in docker-compose.yml, and Docker image references in GitHub Actions and GitLab CI files to prevent supply chain attacks.
A CLI tool that adds `@sha256:<digest>` to `FROM` and `COPY --from=` lines in Dockerfiles, `image` fields in docker-compose.yml, and Docker image references in GitHub Actions and GitLab CI files to prevent supply chain attacks.

## Install

Expand Down Expand Up @@ -77,6 +77,8 @@ dockerfile-pin run --write --update --min-age 7
FROM node:20.11.1
FROM python:3.12-slim AS builder
FROM scratch
COPY --from=nginx:1.27 /etc/nginx /etc/nginx
COPY --from=builder /app /app
```

**After:**
Expand All @@ -85,6 +87,8 @@ FROM scratch
FROM node:20.11.1@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5
FROM python:3.12-slim@sha256:3d5ed973e45820f5ba5e46bd065bd88b3a504ff0724d85980dcd05eab361fcf4 AS builder
FROM scratch
COPY --from=nginx:1.27@sha256:6784fb08b4b7c3b6bcd3f4a1b4d1b1f3e3b7a7ca42ec3e0d9df8a97a2c9a3b1d /etc/nginx /etc/nginx
COPY --from=builder /app /app
```

#### docker-compose.yml
Expand Down Expand Up @@ -295,6 +299,34 @@ ignore-images:
| `ARG BASE` + `FROM ${BASE}` (no default) | Skipped with warning |
| `FROM ghcr.io/org/image:tag` | Yes |
| `FROM registry:5000/image:tag` | Yes |
| `COPY --from=image:tag` | Yes |
| `COPY --from=image:tag@sha256:...` (already pinned) | Skipped (use `--update` to refresh) |
| `COPY --from=<stage-name>` (multi-stage ref) | Skipped |
| `COPY --from=0` (stage index) | Skipped |
| `COPY --from=scratch` | Skipped |
| `COPY --from=image:${TAG}` | Skipped (BuildKit does not expand variables here) |
| `COPY /src /dst` (build context) | Nothing to pin |
| `ONBUILD COPY --from=image:tag` | Yes |
| `# escape=` directive | Honored |
| `ADD --from=...`, `RUN --mount=...,from=...` | Not supported |

An `ONBUILD COPY --from=name` is resolved as an image even when a stage in the same
file shares that name. The trigger does not run in this build: it is recorded into the
image config and executed later, inside whichever build uses this image as its base,
against that Dockerfile's stages — the ones declared here are gone by then.

A digest makes a name an image even when a build stage shares it: BuildKit matches the
whole value against its stage names, so `COPY --from=nginx@sha256:...` finds no stage
named `nginx` and is resolved from the registry. The same holds for `FROM`.

A `COPY --from=<name>` that matches no build stage is treated as an image, the same
way `FROM ubuntu` is. A [named build context](https://docs.docker.com/reference/cli/docker/buildx/build/#build-context)
(`docker buildx build --build-context name=...`) is written the same way and cannot
be told apart from the Dockerfile alone; use `--ignore-images` to exclude one.

Variables are not expanded in `COPY --from=`: BuildKit reads the value verbatim and
the build fails with `failed to parse stage name` ([moby/buildkit#2374](https://github.com/moby/buildkit/issues/2374)),
so such a reference is reported as skipped rather than pinned. `FROM` does expand them.

### docker-compose.yml

Expand Down
4 changes: 2 additions & 2 deletions cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ import (

var checkCmd = &cobra.Command{
Use: "check",
Short: "Check if FROM images are pinned to digests",
Short: "Check if FROM and COPY --from images are pinned to digests",
Long: `Validate that every Docker image reference has a @sha256:<digest> and that the
digest exists in the registry.

Each image is reported as one of:
OK digest present and verified in registry
FAIL missing digest, or digest not found in registry
SKIP scratch, multi-stage ref, ignored, non-Docker uses, or CI variable
SKIP scratch, multi-stage ref, stage index, ignored, non-Docker uses, or CI variable
WARN registry check failed (network error, auth issue, etc.)

Exit code is 1 (configurable with --exit-code) when any image has FAIL status.
Expand Down
49 changes: 49 additions & 0 deletions cmd/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,52 @@ test:
}
}
}

// TestParseDockerfileForCheck_CopyFrom covers the status and the line `check` reports
// for each form of COPY --from.
func TestParseDockerfileForCheck_CopyFrom(t *testing.T) {
content := `FROM golang:1.22@sha256:golang111 AS builder
COPY --from=builder /app /app
COPY --from=0 /go/bin/tool /usr/local/bin/tool
COPY --from=nginx:1.27 /etc/nginx /etc/nginx
COPY --from=busybox:1.36@sha256:busybox222 /bin/busybox /bin/busybox
COPY --from=ghcr.io/myorg/tool:v1 /tool /tool
COPY --from=nginx:${NGINX_VERSION} /etc/nginx /etc/nginx.orig
COPY ./config /config
`
dir := t.TempDir()
path := filepath.Join(dir, "Dockerfile")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}

results, err := parseDockerfileForCheck(path, true, []string{"ghcr.io/myorg/*"})
if err != nil {
t.Fatalf("parseDockerfileForCheck() error = %v", err)
}

want := []struct {
image string
status string
line int
original string
}{
{"golang:1.22", "ok", 1, "FROM golang:1.22@sha256:golang111 AS builder"},
{"builder", "skip", 2, "COPY --from=builder /app /app"},
{"0", "skip", 3, "COPY --from=0 /go/bin/tool /usr/local/bin/tool"},
{"nginx:1.27", "fail", 4, "COPY --from=nginx:1.27 /etc/nginx /etc/nginx"},
{"busybox:1.36", "ok", 5, "COPY --from=busybox:1.36@sha256:busybox222 /bin/busybox /bin/busybox"},
{"ghcr.io/myorg/tool:v1", "skip", 6, "COPY --from=ghcr.io/myorg/tool:v1 /tool /tool"},
{"nginx:${NGINX_VERSION}", "skip", 7, "COPY --from=nginx:${NGINX_VERSION} /etc/nginx /etc/nginx.orig"},
}
if len(results) != len(want) {
t.Fatalf("got %d results, want %d: %+v", len(results), len(want), results)
}
for i, w := range want {
got := results[i]
if got.Image != w.image || got.Status != w.status || got.Line != w.line || got.Original != w.original {
t.Errorf("[%d] got %+v, want image=%q status=%q line=%d original=%q",
i, got, w.image, w.status, w.line, w.original)
}
}
}
16 changes: 12 additions & 4 deletions cmd/pin.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,20 @@ import (

var runCmd = &cobra.Command{
Use: "run",
Short: "Pin FROM images to their digests",
Short: "Pin FROM and COPY --from images to their digests",
Long: `Resolve image tags to sha256 digests and add @sha256:<digest> to each reference.
By default, prints the rewritten file to stdout without modifying it (dry-run).
Use --write to apply changes in place.

Supports Dockerfiles, docker-compose.yml/compose.yaml, GitHub Actions workflows,
action.yml files, and .gitlab-ci.yml. File type is detected from filename.

Both "FROM image:tag" and "COPY --from=image:tag" are pinned.

Skipped automatically:
- "FROM scratch" (no registry image)
- Multi-stage references ("FROM builder")
- Multi-stage references ("FROM builder", "COPY --from=builder", "COPY --from=0")
- Variables in "COPY --from" (BuildKit does not expand them)
- ARG-only base images with no default value
- Compose services with a "build:" directive
- Non-docker "uses:" in GitHub Actions (e.g., actions/checkout@v4)
Expand Down Expand Up @@ -300,7 +303,12 @@ func applyDockerfile(pf parsedFile, digestMap map[string]string, dryRun bool, up
if len(digests) == 0 {
return
}
result := dockerfile.RewriteFile(string(pf.content), pf.dockerInsts, digests)
result, unrewritten := dockerfile.RewriteFileReport(string(pf.content), pf.dockerInsts, digests)
for _, i := range unrewritten {
inst := pf.dockerInsts[i]
fmt.Fprintf(os.Stderr, "WARN %s:%d %s reference not found in the source line, left unchanged\n",
pf.path, inst.StartLine, inst.ImageRef)
}
if dryRun {
fmt.Printf("--- %s\n", pf.path)
fmt.Print(result)
Expand All @@ -310,7 +318,7 @@ func applyDockerfile(pf parsedFile, digestMap map[string]string, dryRun bool, up
fmt.Fprintf(os.Stderr, "error writing %s: %v\n", pf.path, err)
return
}
fmt.Printf("pinned %d image(s) in %s\n", len(digests), pf.path)
fmt.Printf("pinned %d image(s) in %s\n", len(digests)-len(unrewritten), pf.path)
}

func applyActions(pf parsedFile, digestMap map[string]string, dryRun bool, update bool) {
Expand Down
132 changes: 132 additions & 0 deletions cmd/pin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -510,3 +510,135 @@ func TestResolveParallel_MinAge_CreatedTimeErrorPinsAnyway(t *testing.T) {
t.Errorf("expected node:20 to be pinned despite age-check failure, got %q", results["node:20"])
}
}

const copyFromDockerfile = `FROM golang:1.22 AS builder
COPY --from=builder /app /app
COPY --from=0 /go/bin/tool /usr/local/bin/tool
COPY --from=nginx:1.27 /etc/nginx /etc/nginx
COPY --chown=65532:65532 --from=ghcr.io/myorg/tool:v1 /tool /tool
COPY ./config /config
`

// TestParseFile_DockerfileCollectsCopyFromRefs checks which references `run` sends to
// the registry: external images from COPY --from, but never a stage name or index.
func TestParseFile_DockerfileCollectsCopyFromRefs(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "Dockerfile")
if err := os.WriteFile(path, []byte(copyFromDockerfile), 0644); err != nil {
t.Fatal(err)
}

pf, err := parseFile(path, false, nil)
if err != nil {
t.Fatalf("parseFile() error = %v", err)
}

want := []string{"golang:1.22", "nginx:1.27", "ghcr.io/myorg/tool:v1"}
if len(pf.imageRefs) != len(want) {
t.Fatalf("imageRefs = %v, want %v", pf.imageRefs, want)
}
for i, w := range want {
if pf.imageRefs[i] != w {
t.Errorf("imageRefs[%d] = %q, want %q", i, pf.imageRefs[i], w)
}
}
}

// TestParseFile_DockerfileIgnoresCopyFromImages checks that --ignore-images applies to
// COPY --from as well, which is how a named build context is excluded.
func TestParseFile_DockerfileIgnoresCopyFromImages(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "Dockerfile")
if err := os.WriteFile(path, []byte(copyFromDockerfile), 0644); err != nil {
t.Fatal(err)
}

pf, err := parseFile(path, false, []string{"ghcr.io/myorg/*"})
if err != nil {
t.Fatalf("parseFile() error = %v", err)
}

for _, ref := range pf.imageRefs {
if ref == "ghcr.io/myorg/tool:v1" {
t.Errorf("ignored image %q should not be resolved: %v", ref, pf.imageRefs)
}
}
if len(pf.imageRefs) != 2 {
t.Errorf("imageRefs = %v, want golang:1.22 and nginx:1.27", pf.imageRefs)
}
}

// TestApplyDockerfile_PinsCopyFrom writes a pinned Dockerfile the way `run --write`
// does and checks the file on disk.
func TestApplyDockerfile_PinsCopyFrom(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "Dockerfile")
if err := os.WriteFile(path, []byte(copyFromDockerfile), 0644); err != nil {
t.Fatal(err)
}

instructions, err := dockerfile.Parse(strings.NewReader(copyFromDockerfile))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
pf := parsedFile{
path: path,
fileType: FileTypeDockerfile,
dockerInsts: instructions,
content: []byte(copyFromDockerfile),
}
digestMap := map[string]string{
"golang:1.22": "sha256:golang111",
"nginx:1.27": "sha256:nginx222",
"ghcr.io/myorg/tool:v1": "sha256:tool333",
}

applyDockerfile(pf, digestMap, false, false)

result, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(result)
want := `FROM golang:1.22@sha256:golang111 AS builder
COPY --from=builder /app /app
COPY --from=0 /go/bin/tool /usr/local/bin/tool
COPY --from=nginx:1.27@sha256:nginx222 /etc/nginx /etc/nginx
COPY --chown=65532:65532 --from=ghcr.io/myorg/tool:v1@sha256:tool333 /tool /tool
COPY ./config /config
`
if got != want {
t.Errorf("applyDockerfile() wrote:\n%s\nwant:\n%s", got, want)
}
}

// TestApplyDockerfile_UpdateCopyFromDigest covers `run --write --update` re-resolving
// a COPY --from that is already pinned.
func TestApplyDockerfile_UpdateCopyFromDigest(t *testing.T) {
content := "FROM ubuntu:24.04@sha256:oldubuntu\nCOPY --from=nginx:1.27@sha256:oldnginx /etc/nginx /etc/nginx\n"
dir := t.TempDir()
path := filepath.Join(dir, "Dockerfile")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}

instructions, err := dockerfile.Parse(strings.NewReader(content))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
pf := parsedFile{path: path, fileType: FileTypeDockerfile, dockerInsts: instructions, content: []byte(content)}

applyDockerfile(pf, map[string]string{
"ubuntu:24.04": "sha256:newubuntu",
"nginx:1.27": "sha256:newnginx",
}, false, true)

result, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := "FROM ubuntu:24.04@sha256:newubuntu\nCOPY --from=nginx:1.27@sha256:newnginx /etc/nginx /etc/nginx\n"
if string(result) != want {
t.Errorf("applyDockerfile() wrote:\n%s\nwant:\n%s", string(result), want)
}
}
Loading