diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index 816df3a..34861e5 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -1,5 +1,13 @@ # Open audit items +## 2026-09-25 — local image identity + +Full local `sha256:<64hex>` image IDs now count as immutable in deployment +plans and receipts. An existing ID is used without a registry request; an absent +ID refuses with build/load guidance. Named mutable tags still pull on every +deploy. Regression tests cover both cache states and the provenance identity, +including malformed digests. Full Go vet and tests passed. + Unresolved findings for this repository from the ChatGPT-led audit series. Pass 1-5 (2026-09-09 through 2026-09-11, register: teploy-neutron-lullmail expanded audit) closed fully below. Pass 6 (2026-09-17, 78 findings F01-F78, diff --git a/internal/cli/deploy.go b/internal/cli/deploy.go index fbe8b18..8b1b53c 100644 --- a/internal/cli/deploy.go +++ b/internal/cli/deploy.go @@ -1320,12 +1320,15 @@ func healthConfigFrom(h config.AppHealthConfig) deploy.HealthConfig { } // isDigestPinned reports whether an image reference is content-addressed -// (`repo@sha256:...`). Such a reference names exactly one set of bytes forever, +// (`repo@sha256:...` or a full local `sha256:...` ID). Such a reference names exactly one set of bytes forever, // so a local copy of it can never be out of date. Everything else — every tag, // and a bare repo (which Docker resolves to `:latest`) — is mutable: the // registry can move it under us at any time, and "looks like a git sha" is a // convention nothing enforces, so tags are not special-cased here. func isDigestPinned(image string) bool { + if deploy.ImageDigestFromRef(image) != "" { + return true + } i := strings.LastIndex(image, "@") if i < 0 { return false @@ -1366,6 +1369,9 @@ func ensureImage(ctx context.Context, dk *docker.Client, image string, out io.Wr fmt.Fprintf(out, " Using local image %s (digest-pinned, cannot be stale)\n", image) return nil } + if strings.HasPrefix(image, "sha256:") && deploy.ImageDigestFromRef(image) != "" { + return fmt.Errorf("local image ID %s is absent from the server; build or load it there before deploying", image) + } fmt.Fprintf(out, "Pulling image %s...\n", image) if err := dk.Pull(ctx, image); err != nil { if exists { diff --git a/internal/cli/deploy_test.go b/internal/cli/deploy_test.go index adbec73..9e74109 100644 --- a/internal/cli/deploy_test.go +++ b/internal/cli/deploy_test.go @@ -208,3 +208,28 @@ func TestIsDigestPinned(t *testing.T) { } } } + +func TestEnsureImageLocalIDNeverPulls(t *testing.T) { + image := "sha256:" + strings.Repeat("b", 64) + for _, exists := range []bool{true, false} { + response := "" + if exists { + response = "exists\n" + } + mock := ssh.NewMockExecutor("host", ssh.MockCommand{Match: "err=$(mktemp); if docker image inspect", Output: response}) + var out bytes.Buffer + err := ensureImage(context.Background(), docker.NewClient(mock), image, &out) + if exists && err != nil { + t.Fatal(err) + } + if !exists && (err == nil || !strings.Contains(err.Error(), "build or load")) { + t.Fatalf("missing image: %v", err) + } + if pullAttempted(mock) { + t.Fatal("local image ID must never cause a registry request") + } + if exists && !strings.Contains(out.String(), "digest-pinned") { + t.Fatalf("identity misclassified: %s", out.String()) + } + } +} diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go index 98c5eef..ac66ad8 100644 --- a/internal/deploy/deploy.go +++ b/internal/deploy/deploy.go @@ -1762,14 +1762,18 @@ func containerPort(c Config) int { } // ImageDigestFromRef extracts the digest of a digest-pinned image -// reference ("repo@sha256:<64hex>"), or "" for every other reference +// reference ("repo@sha256:<64hex>") or a full local "sha256:<64hex>" ID, +// or "" for every other reference // shape. Exported for the CLI's plan-time provenance, which must apply // the SAME like-for-like rule the deployed record applies (a pinned ref // is identified by its manifest digest, a mutable ref by docker's // resolved content ID) or plan/receipt equality compares apples to // oranges. func ImageDigestFromRef(image string) string { - if _, digest, ok := strings.Cut(image, "@"); ok && strings.HasPrefix(digest, "sha256:") && len(digest) == len("sha256:")+64 { + if strings.HasPrefix(image, "sha256:") && len(image) == 71 && docker.IsImageID(image) { + return image + } + if repo, digest, ok := strings.Cut(image, "@"); ok && repo != "" && strings.HasPrefix(digest, "sha256:") && len(digest) == 71 && docker.IsImageID(digest) { return digest } return "" diff --git a/internal/deploy/provenance_test.go b/internal/deploy/provenance_test.go index 4beb618..ec67850 100644 --- a/internal/deploy/provenance_test.go +++ b/internal/deploy/provenance_test.go @@ -200,3 +200,15 @@ func TestDeploy_NoPlanDigestNoFalseAlarm(t *testing.T) { t.Errorf("nothing to compare must not warn:\n%s", buf.String()) } } + +func TestFullLocalImageIDIsItsOwnProvenance(t *testing.T) { + id := "sha256:" + strings.Repeat("b", 64) + if got := plannedImageDigest(id, id, nil); got != id { + t.Fatalf("got %q, want %q", got, id) + } + for _, invalid := range []string{"sha256:" + strings.Repeat("z", 64), "sha256:abcdef123456", "@" + id, "repo@sha256:" + strings.Repeat("z", 64)} { + if got := ImageDigestFromRef(invalid); got != "" { + t.Fatalf("invalid identity %q returned %q", invalid, got) + } + } +}