From a4b9150fa01f28b485d6169d1b1a6c4f1938bf98 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Tue, 20 Oct 2026 15:02:05 +0100 Subject: [PATCH] sec(build): build Dockerfile.dev's migrate from source like the production image Dockerfile.dev still downloaded the prebuilt golang-migrate v4.17.0 release tarball. That binary is built on upstream's go1.21.5 with upstream's own dependency pins, and govulncheck -mode=binary reports 130 advisories in it. The production Dockerfile stopped doing this in cli#1833/#1835/#1849 and builds migrate from this module's go.mod on the pinned Go image with -tags=pgx5. Dockerfile.dev now uses the same RUN instruction as Dockerfile's builder stage, placed after `go mod download` so the version comes from go.mod (v4.19.1). The resulting binary reports go1.26.9, -tags=pgx5, and no govulncheck findings. The two images are separate Dockerfiles, so they cannot share a build stage. TestDockerfilesBuildMigrateIdentically parses both files and fails if Dockerfile.dev's migrate RUN instruction or its stage's digest-pinned base image differ from Dockerfile's, if either file pulls in a prebuilt migrate (release tarball or migrate/migrate image), or if Dockerfile stops building with go build, no @version suffix and -tags=pgx5. Closes #203 Co-Authored-By: Claude Opus 5.5 --- Dockerfile.dev | 38 ++--- .../migrations/dockerfile_migrate_test.go | 132 ++++++++++++++++++ .../migrations/migrate_security_test.go | 3 +- 3 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 internal/database/postgres/migrations/dockerfile_migrate_test.go diff --git a/Dockerfile.dev b/Dockerfile.dev index 7c8bbcd34..21e5e1d51 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -4,7 +4,10 @@ # To refresh: `docker buildx imagetools inspect golang:1.26.9-alpine3.24` # (or use the Docker Hub API tags endpoint) and update the digest below. # A Renovate / Dependabot config can automate this if desired. -# Keep this digest in sync with the builder stage in Dockerfile. +# Keep this digest in sync with the builder stage in Dockerfile; the +# golang-migrate binary below is built on it, and +# internal/database/postgres/migrations/dockerfile_migrate_test.go fails when +# the two drift. FROM golang:1.26.9-alpine3.24@sha256:cdfd4fe2da6b225d8b40c6b7a105736e548e83ff56d5d8f9394446eeb5eb84e0 AS development # Install development tools and Air for hot reload @@ -18,21 +21,6 @@ RUN apk add --no-cache \ # Set shell with pipefail for safer pipe operations SHELL ["/bin/ash", "-eo", "pipefail", "-c"] -# Install golang-migrate for database migrations (architecture-aware, checksum-verified) -ARG TARGETARCH -RUN MIGRATE_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") && \ - if [ "$MIGRATE_ARCH" = "arm64" ]; then \ - MIGRATE_SHA256="9c95441cc430ffdac89276d14de5e2f18bfafca00796c2895490d62e3776d104"; \ - else \ - MIGRATE_SHA256="26c53c9162c9c4aaa84c47cd12455d4a9ac725befbe82850a5937b5ec1e7b8e6"; \ - fi && \ - curl -Lo migrate.tar.gz "https://github.com/golang-migrate/migrate/releases/download/v4.17.0/migrate.linux-${MIGRATE_ARCH}.tar.gz" && \ - echo "${MIGRATE_SHA256} migrate.tar.gz" | sha256sum -c - && \ - tar xvzf migrate.tar.gz && \ - mv migrate /usr/local/bin/migrate && \ - chmod +x /usr/local/bin/migrate && \ - rm migrate.tar.gz - WORKDIR /app # Copy go mod files @@ -41,6 +29,24 @@ COPY go.mod go.sum ./ # Download dependencies RUN go mod download +# Build golang-migrate from source on this image's pinned Go toolchain, exactly +# as the builder stage in Dockerfile does; see the comment there for why (a +# prebuilt release tarball carries upstream's toolchain and dependency pins, +# and -tags=pgx5 keeps lib/pq out of the binary). The version comes from +# go.mod. The RUN instruction is kept identical to Dockerfile's, so the dev +# container runs the same migrate binary as production, and +# internal/database/postgres/migrations/dockerfile_migrate_test.go fails if +# the two diverge. Issue #203. +ARG TARGETARCH +ARG TARGETOS=linux +RUN MIGRATE_VERSION="$(go list -m -f '{{.Version}}' github.com/golang-migrate/migrate/v4)" && \ + echo "Building golang-migrate ${MIGRATE_VERSION} for ${TARGETOS}/${TARGETARCH}" && \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ + -tags=pgx5 \ + -ldflags="-s -w -X main.Version=${MIGRATE_VERSION}" \ + -o /usr/local/bin/migrate \ + github.com/golang-migrate/migrate/v4/cmd/migrate + # Copy source code COPY . . diff --git a/internal/database/postgres/migrations/dockerfile_migrate_test.go b/internal/database/postgres/migrations/dockerfile_migrate_test.go new file mode 100644 index 000000000..c7481d8e8 --- /dev/null +++ b/internal/database/postgres/migrations/dockerfile_migrate_test.go @@ -0,0 +1,132 @@ +package migrations + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// prebuiltMigrateSources are the places a prebuilt golang-migrate binary comes +// from: upstream's release tarballs and its Docker Hub image (via FROM or +// COPY --from). A prebuilt binary carries whatever Go toolchain and dependency +// pins upstream built it with, so no base-image or go.mod bump in this repo +// reaches it (issues #1833 and #203). +var prebuiltMigrateSources = []string{ + "golang-migrate/migrate/releases", + "migrate/migrate:", + "migrate/migrate@", +} + +// TestDockerfilesBuildMigrateIdentically pins the development image's +// golang-migrate binary to the production one. Both Dockerfiles must build it +// from source with one identical RUN instruction, in a stage based on the same +// digest-pinned Go image, and neither may download a prebuilt release. +// +// The two images are separate Dockerfiles, so they cannot share a build stage; +// this test is what stops them drifting apart again. Dockerfile.dev downloaded +// the v4.17.0 release tarball long after Dockerfile switched to building +// v4.19.1 from this module (issue #203). +func TestDockerfilesBuildMigrateIdentically(t *testing.T) { + root := mainModuleRoot(t) + prod := parseDockerfile(t, filepath.Join(root, "Dockerfile")) + dev := parseDockerfile(t, filepath.Join(root, "Dockerfile.dev")) + + for name, instrs := range map[string][]string{"Dockerfile": prod, "Dockerfile.dev": dev} { + for _, instr := range instrs { + for _, src := range prebuiltMigrateSources { + assert.NotContains(t, instr, src, + "%s pulls in a prebuilt golang-migrate binary; build it from this module "+ + "instead, as the RUN instruction in Dockerfile does", name) + } + } + } + + prodRun, prodBase := migrateBuildInstruction(t, "Dockerfile", prod) + devRun, devBase := migrateBuildInstruction(t, "Dockerfile.dev", dev) + + assert.Contains(t, prodRun, " go build ", + "Dockerfile must build migrate with `go build` as a package of this module") + assert.NotContains(t, prodRun, migrateCLIPackage+"@", + "an @version suffix resolves golang-migrate's own go.mod instead of this module's") + assert.Contains(t, prodRun, "-tags="+migrateCLIBuildTag, + "Dockerfile must build migrate with -tags=%s so lib/pq is not linked", migrateCLIBuildTag) + + assert.Equal(t, prodRun, devRun, + "Dockerfile.dev must build golang-migrate with exactly the RUN instruction Dockerfile uses") + assert.Contains(t, prodBase, "@sha256:", "Dockerfile's migrate build stage must be digest-pinned") + assert.Equal(t, prodBase, devBase, + "Dockerfile.dev must build golang-migrate on the same pinned Go image as Dockerfile") +} + +// migrateBuildInstruction returns the single RUN instruction in instrs that +// builds the migrate CLI, and the base image of the stage it runs in. +func migrateBuildInstruction(t *testing.T, name string, instrs []string) (run, base string) { + t.Helper() + + var runs []string + stageBase := "" + for _, instr := range instrs { + keyword, args, _ := strings.Cut(instr, " ") + switch strings.ToUpper(keyword) { + case "FROM": + stageBase = stageBaseImage(args) + case "RUN": + if strings.Contains(args, migrateCLIPackage) { + runs = append(runs, instr) + run, base = instr, stageBase + } + } + } + + require.Lenf(t, runs, 1, "%s must contain exactly one RUN instruction that builds %s, found %d: %q", + name, migrateCLIPackage, len(runs), runs) + return run, base +} + +// stageBaseImage returns the image reference of a FROM instruction's +// arguments, skipping flags such as --platform and dropping any `AS name`. +func stageBaseImage(args string) string { + for _, field := range strings.Fields(args) { + if !strings.HasPrefix(field, "--") { + return field + } + } + return "" +} + +// parseDockerfile returns the file's instructions with line continuations +// joined, comment and blank lines dropped (Docker strips both, including inside +// a continued instruction), and runs of whitespace collapsed to one space so +// that indentation differences do not count as drift. Neither Dockerfile sets +// an escape directive, so the continuation character is the default backslash. +func parseDockerfile(t *testing.T, path string) []string { + t.Helper() + + data, err := os.ReadFile(path) + require.NoErrorf(t, err, "reading %s", path) + + var instrs []string + var cur strings.Builder + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + body, continued := strings.CutSuffix(trimmed, `\`) + cur.WriteString(body) + cur.WriteString(" ") + if continued { + continue + } + instrs = append(instrs, strings.Join(strings.Fields(cur.String()), " ")) + cur.Reset() + } + require.Emptyf(t, strings.TrimSpace(cur.String()), "%s ends inside a continued instruction", path) + require.NotEmptyf(t, instrs, "%s has no instructions", path) + + return instrs +} diff --git a/internal/database/postgres/migrations/migrate_security_test.go b/internal/database/postgres/migrations/migrate_security_test.go index 0f5f1015a..160bbd4bd 100644 --- a/internal/database/postgres/migrations/migrate_security_test.go +++ b/internal/database/postgres/migrations/migrate_security_test.go @@ -208,7 +208,8 @@ const ( // Known limit, stated so nobody reads more into it: the CLI subtest pins the // build as configured, with migrateCLIBuildTag. It asserts that build stays // lib/pq-free; it cannot notice someone changing the tag back to `postgres` in -// the Dockerfile, which is a build-configuration change no Go test observes. +// the Dockerfile. TestDockerfilesBuildMigrateIdentically covers that side, by +// reading the RUN instruction the Dockerfiles actually build migrate with. func TestLibPqDriverNotRegistered(t *testing.T) { t.Run("driver not registered", func(t *testing.T) { for _, scheme := range []string{"postgres", "postgresql"} {