diff --git a/cmd/publish-public-ecr-release-tags/main.go b/cmd/publish-public-ecr-release-tags/main.go index dc46fb0..28ce75c 100644 --- a/cmd/publish-public-ecr-release-tags/main.go +++ b/cmd/publish-public-ecr-release-tags/main.go @@ -14,7 +14,10 @@ import ( const defaultRegistryURI = "public.ecr.aws/conductorone" -var sha256DigestPattern = regexp.MustCompile(`^(?:sha256:)?([A-Fa-f0-9]{64})$`) +var ( + sha256DigestPattern = regexp.MustCompile(`^(?:sha256:)?([A-Fa-f0-9]{64})$`) + releaseCandidateTagPattern = regexp.MustCompile(`^release-candidate-[0-9]+-[0-9]+$`) +) type config struct { repositoryName string @@ -24,15 +27,15 @@ type config struct { registryURI string } -type awsRunner interface { +type commandRunner interface { Run(args ...string) ([]byte, []byte, error) } -type execAWSRunner struct { +type execCommandRunner struct { binary string } -func (r execAWSRunner) Run(args ...string) ([]byte, []byte, error) { +func (r execCommandRunner) Run(args ...string) ([]byte, []byte, error) { var stderr bytes.Buffer cmd := exec.Command(r.binary, args...) cmd.Stderr = &stderr @@ -66,8 +69,12 @@ func main() { if awsCLI == "" { awsCLI = "aws" } + dockerCLI := os.Getenv("DOCKER_CLI") + if dockerCLI == "" { + dockerCLI = "docker" + } - if err := publish(cfg, execAWSRunner{binary: awsCLI}, os.Stdout, os.Stderr); err != nil { + if err := publish(cfg, execCommandRunner{binary: awsCLI}, execCommandRunner{binary: dockerCLI}, os.Stdout, os.Stderr); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } @@ -96,7 +103,7 @@ func (c config) validate() error { return nil } -func publish(cfg config, aws awsRunner, stdout, stderr io.Writer) error { +func publish(cfg config, aws, docker commandRunner, stdout, stderr io.Writer) error { imageBase := strings.TrimRight(cfg.registryURI, "/") + "/" + cfg.repositoryName candidateRef := imageBase + ":" + cfg.candidateTag versionRef := imageBase + ":" + cfg.versionTag @@ -121,13 +128,13 @@ func publish(cfg config, aws awsRunner, stdout, stderr io.Writer) error { fmt.Fprintf(stdout, "Public ECR version tag %s:%s is available\n", cfg.repositoryName, cfg.versionTag) } - manifest, err := imageManifest(aws, cfg.repositoryName, candidateDigest) + manifest, err := imageManifest(docker, imageBase, candidateDigest) if err != nil { return err } if !versionTagExists { - if err := putImageTag(aws, cfg.repositoryName, manifest, cfg.versionTag); err != nil { + if err := putImageTag(aws, cfg.repositoryName, manifest, cfg.versionTag, candidateDigest); err != nil { return err } } else { @@ -145,7 +152,7 @@ func publish(cfg config, aws awsRunner, stdout, stderr io.Writer) error { return fmt.Errorf("::error::Public ECR tag %s:%s points at %s after publication, expected %s", cfg.repositoryName, cfg.versionTag, publishedDigest, candidateDigest) } - if err := putImageTag(aws, cfg.repositoryName, manifest, "latest"); err != nil { + if err := putImageTag(aws, cfg.repositoryName, manifest, "latest", candidateDigest); err != nil { return err } @@ -187,7 +194,7 @@ func candidateDigestFromFile(path, candidateRef string) (string, error) { return "", fmt.Errorf("Could not find candidate ref %s in %s\n%s", candidateRef, path, content) } -func describeImageDigest(aws awsRunner, repositoryName, tag string) (string, error) { +func describeImageDigest(aws commandRunner, repositoryName, tag string) (string, error) { args := []string{ "ecr-public", "describe-images", "--repository-name", repositoryName, @@ -221,43 +228,37 @@ func describeImageDigest(aws awsRunner, repositoryName, tag string) (string, err return digest, nil } -func imageManifest(aws awsRunner, repositoryName, digest string) (string, error) { +func imageManifest(docker commandRunner, imageBase, digest string) (string, error) { + ref := imageBase + "@" + digest + // The raw manifest bytes must hash to digest; PutImage --image-digest and + // the post-write describe check keep this fail-closed if Docker changes. args := []string{ - "ecr-public", "batch-get-image", - "--repository-name", repositoryName, - "--image-ids", "imageDigest=" + digest, - "--accepted-media-types", - "application/vnd.oci.image.index.v1+json", - "application/vnd.docker.distribution.manifest.list.v2+json", - "application/vnd.oci.image.manifest.v1+json", - "application/vnd.docker.distribution.manifest.v2+json", - "--output", "json", + "buildx", "imagetools", "inspect", + "--raw", + ref, } - stdout, stderr, err := aws.Run(args...) + stdout, stderr, err := docker.Run(args...) if err != nil { - return "", awsError(args, stderr, err) + return "", commandError("docker", args, stderr, err) } - var response struct { - Images []struct { - ImageManifest string `json:"imageManifest"` - } `json:"images"` + manifest := strings.TrimSpace(string(stdout)) + if manifest == "" { + return "", fmt.Errorf("Could not fetch manifest for %s\n%s", ref, stdout) } - if err := json.Unmarshal(stdout, &response); err != nil { - return "", fmt.Errorf("publish-public-ecr-release-tags: error: parsing batch-get-image response for %s@%s: %w", repositoryName, digest, err) - } - if len(response.Images) == 0 || response.Images[0].ImageManifest == "" { - return "", fmt.Errorf("Could not fetch manifest for %s@%s\n%s", repositoryName, digest, stdout) + if !json.Valid([]byte(manifest)) { + return "", fmt.Errorf("publish-public-ecr-release-tags: error: docker manifest for %s is not valid JSON", ref) } - return response.Images[0].ImageManifest, nil + return manifest, nil } -func putImageTag(aws awsRunner, repositoryName, manifest, tag string) error { +func putImageTag(aws commandRunner, repositoryName, manifest, tag, digest string) error { args := []string{ "ecr-public", "put-image", "--repository-name", repositoryName, "--image-manifest", manifest, "--image-tag", tag, + "--image-digest", digest, } _, stderr, err := aws.Run(args...) if err != nil { @@ -266,7 +267,11 @@ func putImageTag(aws awsRunner, repositoryName, manifest, tag string) error { return nil } -func deleteImageTag(aws awsRunner, repositoryName, tag string) error { +func deleteImageTag(aws commandRunner, repositoryName, tag string) error { + if !releaseCandidateTagPattern.MatchString(tag) { + return fmt.Errorf("refusing to delete non-candidate Public ECR tag %q", tag) + } + args := []string{ "ecr-public", "batch-delete-image", "--repository-name", repositoryName, @@ -297,9 +302,13 @@ func isImageNotFound(stderr []byte) bool { } func awsError(args []string, stderr []byte, err error) error { + return commandError("aws", args, stderr, err) +} + +func commandError(binary string, args []string, stderr []byte, err error) error { message := strings.TrimSpace(string(stderr)) if message == "" { - return fmt.Errorf("publish-public-ecr-release-tags: error: aws %s: %w", strings.Join(args, " "), err) + return fmt.Errorf("publish-public-ecr-release-tags: error: %s %s: %w", binary, strings.Join(args, " "), err) } - return fmt.Errorf("publish-public-ecr-release-tags: error: aws %s: %s: %w", strings.Join(args, " "), message, err) + return fmt.Errorf("publish-public-ecr-release-tags: error: %s %s: %s: %w", binary, strings.Join(args, " "), message, err) } diff --git a/cmd/publish-public-ecr-release-tags/main_test.go b/cmd/publish-public-ecr-release-tags/main_test.go index 04b3e23..76f06fc 100644 --- a/cmd/publish-public-ecr-release-tags/main_test.go +++ b/cmd/publish-public-ecr-release-tags/main_test.go @@ -17,6 +17,8 @@ var ( digestB = "sha256:" + strings.Repeat("b", 64) ) +const defaultManifest = `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}` + func TestPublishPublicECRTagsPublishesAvailableVersion(t *testing.T) { digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestAHex)) fake := &fakeAWS{ @@ -39,6 +41,15 @@ func TestPublishPublicECRTagsPublishesAvailableVersion(t *testing.T) { if !fake.calledPutTag("latest") { t.Fatal("latest tag was not published") } + if !fake.calledPutTagWithDigest("1.2.3", digestA) { + t.Fatal("version tag was not published with the candidate digest guard") + } + if !fake.calledPutTagWithDigest("latest", digestA) { + t.Fatal("latest tag was not published with the candidate digest guard") + } + if got := fake.putManifestForTag("1.2.3"); got != defaultManifest { + t.Fatalf("version tag manifest = %q, want %q", got, defaultManifest) + } if fake.countDescribeTag("latest") != 0 { t.Fatal("latest must not be part of the ECR release preflight") } @@ -75,6 +86,9 @@ func TestPublishPublicECRTagsKeepsSameVersionIdempotent(t *testing.T) { if !fake.calledPutTag("latest") { t.Fatal("latest tag should still be refreshed") } + if !fake.calledPutTagWithDigest("latest", digestA) { + t.Fatal("latest tag should be refreshed with the candidate digest guard") + } } func TestPublishPublicECRTagsRejectsDifferentExistingDigest(t *testing.T) { @@ -82,15 +96,16 @@ func TestPublishPublicECRTagsRejectsDifferentExistingDigest(t *testing.T) { fake := &fakeAWS{ describeResults: []describeResult{{digest: digestB}}, } + fakeDocker := &fakeDocker{} - _, _, err := runPublishForTest(t, digestFile, fake) + _, _, err := runPublishForTestWithDocker(t, digestFile, fake, fakeDocker) if err == nil { t.Fatal("publish succeeded, want failure") } if !strings.Contains(err.Error(), "already points at "+digestB) { t.Fatalf("error = %v", err) } - if fake.calledCommand("batch-get-image") { + if fakeDocker.calledCommand("imagetools") { t.Fatal("different digest must fail before fetching the candidate manifest") } if fake.calledCommand("put-image") { @@ -167,6 +182,9 @@ func TestPublishPublicECRTagsRejectsPostWriteDigestMismatch(t *testing.T) { if !fake.calledPutTag("1.2.3") { t.Fatal("version tag should be written before post-write verification") } + if !fake.calledPutTagWithDigest("1.2.3", digestA) { + t.Fatal("version tag write should include the candidate digest guard") + } if fake.calledPutTag("latest") { t.Fatal("latest must not be published after version digest verification fails") } @@ -177,10 +195,10 @@ func TestPublishPublicECRTagsRejectsMissingManifest(t *testing.T) { emptyManifest := "" fake := &fakeAWS{ describeResults: []describeResult{{notFound: true}}, - manifest: &emptyManifest, } + fakeDocker := &fakeDocker{manifest: &emptyManifest} - _, _, err := runPublishForTest(t, digestFile, fake) + _, _, err := runPublishForTestWithDocker(t, digestFile, fake, fakeDocker) if err == nil { t.Fatal("publish succeeded, want failure") } @@ -192,22 +210,42 @@ func TestPublishPublicECRTagsRejectsMissingManifest(t *testing.T) { } } -func TestPublishPublicECRTagsFailsClosedOnBatchGetImageError(t *testing.T) { +func TestPublishPublicECRTagsFailsClosedOnManifestInspectError(t *testing.T) { digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestA)) fake := &fakeAWS{ describeResults: []describeResult{{notFound: true}}, - batchGetErr: "AccessDeniedException: denied", } + fakeDocker := &fakeDocker{inspectErr: "manifest unknown"} - _, _, err := runPublishForTest(t, digestFile, fake) + _, _, err := runPublishForTestWithDocker(t, digestFile, fake, fakeDocker) if err == nil { t.Fatal("publish succeeded, want failure") } - if !strings.Contains(err.Error(), "AccessDeniedException") { + if !strings.Contains(err.Error(), "manifest unknown") { + t.Fatalf("error = %v", err) + } + if fake.calledCommand("put-image") { + t.Fatal("manifest inspect errors must fail before tag publication") + } +} + +func TestPublishPublicECRTagsRejectsInvalidManifestJSON(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestA)) + invalidManifest := "not-json" + fake := &fakeAWS{ + describeResults: []describeResult{{notFound: true}}, + } + fakeDocker := &fakeDocker{manifest: &invalidManifest} + + _, _, err := runPublishForTestWithDocker(t, digestFile, fake, fakeDocker) + if err == nil { + t.Fatal("publish succeeded, want failure") + } + if !strings.Contains(err.Error(), "not valid JSON") { t.Fatalf("error = %v", err) } if fake.calledCommand("put-image") { - t.Fatal("batch-get-image errors must fail before tag publication") + t.Fatal("invalid manifest must fail before tag publication") } } @@ -255,7 +293,44 @@ func TestPublishPublicECRTagsCandidateCleanupIsBestEffort(t *testing.T) { } } +func TestPublishPublicECRTagsRefusesUnsafeCandidateCleanup(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:temporary\n", digestA)) + fake := &fakeAWS{ + describeResults: []describeResult{ + {notFound: true}, + {digest: digestA}, + }, + } + cfg := config{ + repositoryName: "bridge-client", + versionTag: "1.2.3", + candidateTag: "temporary", + digestFile: digestFile, + registryURI: defaultRegistryURI, + } + var stdout, stderr bytes.Buffer + + err := publish(cfg, fake, &fakeDocker{}, &stdout, &stderr) + if err != nil { + t.Fatalf("publish: %v", err) + } + if fake.calledCommand("batch-delete-image") { + t.Fatal("unsafe candidate tag must not be sent to BatchDeleteImage") + } + if !strings.Contains(stderr.String(), `refusing to delete non-candidate Public ECR tag "temporary"`) { + t.Fatalf("stderr = %q", stderr.String()) + } + if !strings.Contains(stdout.String(), "Published public.ecr.aws/conductorone/bridge-client:1.2.3") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + func runPublishForTest(t *testing.T, digestFile string, fake *fakeAWS) (string, string, error) { + t.Helper() + return runPublishForTestWithDocker(t, digestFile, fake, &fakeDocker{}) +} + +func runPublishForTestWithDocker(t *testing.T, digestFile string, fake *fakeAWS, fakeDocker *fakeDocker) (string, string, error) { t.Helper() cfg := config{ repositoryName: "bridge-client", @@ -265,7 +340,7 @@ func runPublishForTest(t *testing.T, digestFile string, fake *fakeAWS) (string, registryURI: defaultRegistryURI, } var stdout, stderr bytes.Buffer - err := publish(cfg, fake, &stdout, &stderr) + err := publish(cfg, fake, fakeDocker, &stdout, &stderr) return stdout.String(), stderr.String(), err } @@ -286,8 +361,6 @@ type describeResult struct { type fakeAWS struct { describeResults []describeResult - manifest *string - batchGetErr string putErr string deleteErr string calls [][]string @@ -317,18 +390,6 @@ func (f *fakeAWS) Run(args ...string) ([]byte, []byte, error) { "imageDetails": []map[string]string{{"imageDigest": result.digest}}, }) return stdout, nil, err - case "batch-get-image": - if f.batchGetErr != "" { - return nil, []byte(f.batchGetErr), errors.New("aws failed") - } - manifest := "manifest-json" - if f.manifest != nil { - manifest = *f.manifest - } - stdout, err := json.Marshal(map[string]any{ - "images": []map[string]string{{"imageManifest": manifest}}, - }) - return stdout, nil, err case "put-image": if f.putErr != "" { return nil, []byte(f.putErr), errors.New("aws failed") @@ -344,6 +405,39 @@ func (f *fakeAWS) Run(args ...string) ([]byte, []byte, error) { } } +type fakeDocker struct { + manifest *string + inspectErr string + calls [][]string +} + +func (f *fakeDocker) Run(args ...string) ([]byte, []byte, error) { + f.calls = append(f.calls, append([]string(nil), args...)) + + if len(args) < 4 || args[0] != "buildx" || args[1] != "imagetools" || args[2] != "inspect" { + return nil, []byte("unexpected docker call"), errors.New("docker failed") + } + if f.inspectErr != "" { + return nil, []byte(f.inspectErr), errors.New("docker failed") + } + manifest := defaultManifest + if f.manifest != nil { + manifest = *f.manifest + } + return []byte(manifest), nil, nil +} + +func (f *fakeDocker) calledCommand(command string) bool { + for _, call := range f.calls { + for _, arg := range call { + if arg == command { + return true + } + } + } + return false +} + func (f *fakeAWS) calledCommand(command string) bool { for _, call := range f.calls { if len(call) >= 2 && call[1] == command { @@ -362,6 +456,24 @@ func (f *fakeAWS) calledPutTag(tag string) bool { return false } +func (f *fakeAWS) calledPutTagWithDigest(tag, digest string) bool { + for _, call := range f.calls { + if len(call) >= 2 && call[1] == "put-image" && argValue(call, "--image-tag") == tag && argValue(call, "--image-digest") == digest { + return true + } + } + return false +} + +func (f *fakeAWS) putManifestForTag(tag string) string { + for _, call := range f.calls { + if len(call) >= 2 && call[1] == "put-image" && argValue(call, "--image-tag") == tag { + return argValue(call, "--image-manifest") + } + } + return "" +} + func (f *fakeAWS) countDescribeTag(tag string) int { var count int for _, call := range f.calls {