diff --git a/internal/provider/resource_sign.go b/internal/provider/resource_sign.go index 7145602a..1496f228 100644 --- a/internal/provider/resource_sign.go +++ b/internal/provider/resource_sign.go @@ -34,16 +34,13 @@ type SignResource struct { popts *ProviderOpts } -// TODO: Add Conflict type SignResourceModel struct { Id types.String `tfsdk:"id"` Image types.String `tfsdk:"image"` + Conflict types.String `tfsdk:"conflict"` SignedRef types.String `tfsdk:"signed_ref"` FulcioURL types.String `tfsdk:"fulcio_url"` RekorURL types.String `tfsdk:"rekor_url"` - - // TODO: Support REPLACE and SKIPSAME conflict behavior like attest. - // Conflict types.String `tfsdk:"conflict"` } func (r *SignResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { @@ -70,6 +67,17 @@ func (r *SignResource) Schema(ctx context.Context, req resource.SchemaRequest, r stringplanmodifier.RequiresReplace(), }, }, + "conflict": schema.StringAttribute{ + MarkdownDescription: "How to handle conflicting signature values", + Computed: true, + Optional: true, + Required: false, + Default: stringdefault.StaticString("APPEND"), + Validators: []validator.String{ConflictValidator{}}, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, "signed_ref": schema.StringAttribute{ MarkdownDescription: "This always matches the input digest, but is a convenience for composition.", Computed: true, @@ -135,7 +143,7 @@ func (r *SignResource) doSign(ctx context.Context, data *SignResourceModel) (str // TODO: This should probably be configurable? var annotations map[string]interface{} = nil - if err := secant.Sign(ctx, annotations, sv, rekorClient, []string{digest.String()}, r.popts.ropts); err != nil { + if err := secant.Sign(ctx, data.Conflict.ValueString(), annotations, sv, rekorClient, []string{digest.String()}, r.popts.ropts); err != nil { return "", nil, fmt.Errorf("unable to sign image %q: %w", digest.String(), err) } return digest.String(), nil, nil diff --git a/internal/provider/resource_sign_test.go b/internal/provider/resource_sign_test.go index 2a348360..ffbb16fc 100644 --- a/internal/provider/resource_sign_test.go +++ b/internal/provider/resource_sign_test.go @@ -4,9 +4,11 @@ import ( "fmt" "os" "regexp" + "strings" "testing" ocitesting "github.com/chainguard-dev/terraform-provider-oci/testing" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/random" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -158,3 +160,134 @@ data "cosign_verify" "bar" { }, }) } + +func TestAccResourceCosignSignConflict(t *testing.T) { + if _, ok := os.LookupEnv("ACTIONS_ID_TOKEN_REQUEST_URL"); !ok { + t.Skip("Unable to keylessly sign without an actions token") + } + + repo, cleanup := ocitesting.SetupRepository(t, "test") + defer cleanup() + + img1, err := random.Image(1024, 1) + if err != nil { + t.Fatal(err) + } + dig1, err := img1.Digest() + if err != nil { + t.Fatal(err) + } + ref1 := repo.Digest(dig1.String()) + if err := remote.Write(ref1, img1); err != nil { + t.Fatal(err) + } + + prevDigest := v1.Hash{} + + for i, tc := range []struct { + conflict string + wantCount int + noop bool + }{{ + conflict: "APPEND", + wantCount: 1, + }, { + conflict: "APPEND", + wantCount: 2, + }, { + conflict: "REPLACE", + wantCount: 1, + }, { + conflict: "SKIPSAME", + wantCount: 1, + noop: true, + }} { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + // Depending on the conflict type, we expect to see different behavior: + // - APPEND will just add a signature each time (we call it twice). + // - REPLACE will replace by sig digest and elminate the duplicates, dropping it down to 1. + // - SKIPSAME will see that the digests are the same as what we wrote in REPLACE and will be a noop. + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "cosign_sign" "foo" { + image = %q + + conflict = %q + } + + data "cosign_verify" "bar" { + image = cosign_sign.foo.signed_ref + policy = jsonencode({ + apiVersion = "policy.sigstore.dev/v1beta1" + kind = "ClusterImagePolicy" + metadata = { + name = "signed-it" + } + spec = { + images = [{ + glob = %q + }] + authorities = [{ + keyless = { + url = "https://fulcio.sigstore.dev" + identities = [{ + issuer = "https://token.actions.githubusercontent.com" + subject = "https://github.com/chainguard-dev/terraform-provider-cosign/.github/workflows/test.yml@refs/heads/main" + }] + } + ctlog = { + url = "https://rekor.sigstore.dev" + } + }] + } + }) + } + `, ref1, tc.conflict, ref1), + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr( + "cosign_sign.foo", "image", regexp.MustCompile("^"+ref1.String())), + resource.TestMatchResourceAttr( + "cosign_sign.foo", "signed_ref", regexp.MustCompile("^"+ref1.String())), + // Check that it got signed! + resource.TestMatchResourceAttr( + "data.cosign_verify.bar", "verified_ref", regexp.MustCompile("^"+ref1.String())), + ), + }, + }, + }) + + sigRef := ref1.Tag(strings.ReplaceAll(dig1.String(), ":", "-") + ".sig") + att, err := remote.Image(sigRef) + if err != nil { + t.Fatal(err) + } + + if got, want := countAttestations(t, att), tc.wantCount; got != want { + t.Errorf("got %d attestation layers, want %d", got, want) + } + + nextDigest, err := att.Digest() + if err != nil { + t.Fatal(err) + } + + if prevDigest != (v1.Hash{}) { + if tc.noop { + if prevDigest != nextDigest { + t.Errorf("expected noop, but attestation was updated") + } + } else { + if prevDigest == nextDigest { + t.Errorf("expected attestation to change, but saw noop") + } + } + } + + prevDigest = nextDigest + }) + } +} diff --git a/internal/secant/attest.go b/internal/secant/attest.go index d0342eca..63096123 100644 --- a/internal/secant/attest.go +++ b/internal/secant/attest.go @@ -173,7 +173,7 @@ func Attest(ctx context.Context, conflict string, statements []*types.Statement, signOpts := []mutate.SignOption{} if conflict != "APPEND" { - signOpts = append(signOpts, mutate.WithReplaceOp(newReplaceOp(predicateType))) + signOpts = append(signOpts, mutate.WithReplaceOp(replacePredicate(predicateType))) } // Attach the attestation to the entity. diff --git a/internal/secant/replace.go b/internal/secant/replace.go index 7017a0bd..4a7e2842 100644 --- a/internal/secant/replace.go +++ b/internal/secant/replace.go @@ -9,7 +9,7 @@ import ( "github.com/sigstore/cosign/v2/pkg/oci" ) -func newReplaceOp(predicateType string) *ro { +func replacePredicate(predicateType string) *ro { return &ro{predicateType: predicateType} } @@ -29,7 +29,7 @@ func (r *ro) Replace(signatures oci.Signatures, o oci.Signature) (oci.Signatures sigsCopy = append(sigsCopy, o) if len(sigs) == 0 { - ros.attestations = append(ros.attestations, sigsCopy...) + ros.sigs = append(ros.sigs, sigsCopy...) return ros, nil } @@ -48,7 +48,7 @@ func (r *ro) Replace(signatures oci.Signatures, o oci.Signature) (oci.Signatures sigsCopy = append(sigsCopy, s) } - ros.attestations = append(ros.attestations, sigsCopy...) + ros.sigs = append(ros.sigs, sigsCopy...) return ros, nil } @@ -102,9 +102,9 @@ func getPredicateType(s sigsubset) (string, error) { type replaceOCISignatures struct { oci.Signatures - attestations []oci.Signature + sigs []oci.Signature } func (r *replaceOCISignatures) Get() ([]oci.Signature, error) { - return r.attestations, nil + return r.sigs, nil } diff --git a/internal/secant/sign.go b/internal/secant/sign.go index c6f793bd..593dd785 100644 --- a/internal/secant/sign.go +++ b/internal/secant/sign.go @@ -4,13 +4,13 @@ import ( "bytes" "context" "fmt" + "os" "github.com/chainguard-dev/terraform-provider-cosign/internal/secant/rekor" "github.com/chainguard-dev/terraform-provider-cosign/internal/secant/types" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" - cremote "github.com/sigstore/cosign/v2/pkg/cosign/remote" "github.com/sigstore/cosign/v2/pkg/oci" "github.com/sigstore/cosign/v2/pkg/oci/mutate" ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote" @@ -20,11 +20,22 @@ import ( ) // Sign is roughly equivalent to cosign sign. -func Sign(ctx context.Context, annotations map[string]interface{}, sv types.CosignerVerifier, rekorClient *client.Rekor, imgs []string, ropt []remote.Option) error { - dd := cremote.NewDupeDetector(sv) +func Sign(ctx context.Context, conflict string, annotations map[string]interface{}, sv types.CosignerVerifier, rekorClient *client.Rekor, imgs []string, ropt []remote.Option) error { cs := rekor.NewCosigner(sv, rekorClient) opts := []ociremote.Option{ociremote.WithRemoteOptions(ropt...)} + signOpts := []mutate.SignOption{} + switch conflict { + case "APPEND": + // Don't add any options. Without replace op or dupe detector, we will append. + case "REPLACE": + signOpts = append(signOpts, mutate.WithReplaceOp(replaceSignatures{})) + case "SKIPSAME": + signOpts = append(signOpts, mutate.WithDupeDetector(skipSameSignatures{})) + default: + // This should not happen because schema validation would catch it. + return fmt.Errorf("unhandled conflict type: %q", conflict) + } for _, inputImg := range imgs { ref, err := name.ParseReference(inputImg) @@ -44,7 +55,7 @@ func Sign(ctx context.Context, annotations map[string]interface{}, sv types.Cosi return fmt.Errorf("computing digest: %w", err) } digest := ref.Context().Digest(d.String()) - if err := signDigest(ctx, digest, annotations, dd, cs, se, opts); err != nil { + if err := signDigest(ctx, digest, annotations, signOpts, cs, se, opts); err != nil { return fmt.Errorf("signing digest: %w", err) } return nil @@ -56,7 +67,7 @@ func Sign(ctx context.Context, annotations map[string]interface{}, sv types.Cosi return nil } -func signDigest(ctx context.Context, digest name.Digest, annotations map[string]interface{}, dd mutate.DupeDetector, cs types.Cosigner, se oci.SignedEntity, opts []ociremote.Option) error { +func signDigest(ctx context.Context, digest name.Digest, annotations map[string]interface{}, signOpts []mutate.SignOption, cs types.Cosigner, se oci.SignedEntity, opts []ociremote.Option) error { payload, err := (&sigPayload.Cosign{ Image: digest, Annotations: annotations, @@ -71,7 +82,7 @@ func signDigest(ctx context.Context, digest name.Digest, annotations map[string] } // Attach the signature to the entity. - newSE, err := mutate.AttachSignatureToEntity(se, ociSig, mutate.WithDupeDetector(dd)) + newSE, err := mutate.AttachSignatureToEntity(se, ociSig, signOpts...) if err != nil { return err } @@ -79,3 +90,73 @@ func signDigest(ctx context.Context, digest name.Digest, annotations map[string] // Publish the signatures associated with this entity return ociremote.WriteSignatures(digest.Repository, newSE, opts...) } + +type replaceSignatures struct{} + +func (r replaceSignatures) Replace(signatures oci.Signatures, o oci.Signature) (oci.Signatures, error) { + sigs, err := signatures.Get() + if err != nil { + return nil, err + } + + ros := &replaceOCISignatures{Signatures: signatures} + + sigsCopy := make([]oci.Signature, 0, len(sigs)) + sigsCopy = append(sigsCopy, o) + + if len(sigs) == 0 { + ros.sigs = append(ros.sigs, sigsCopy...) + return ros, nil + } + + digest, err := o.Digest() + if err != nil { + return nil, err + } + + for _, s := range sigs { + existingDigest, err := s.Digest() + if err != nil { + return nil, err + } + + if digest == existingDigest { + fmt.Fprintln(os.Stderr, "Replacing signature with digest:", digest) + continue + } + + fmt.Fprintln(os.Stderr, "Not replacing signature with digest:", digest) + sigsCopy = append(sigsCopy, s) + } + + ros.sigs = append(ros.sigs, sigsCopy...) + + return ros, nil +} + +type skipSameSignatures struct{} + +func (r skipSameSignatures) Find(signatures oci.Signatures, o oci.Signature) (oci.Signature, error) { + sigs, err := signatures.Get() + if err != nil { + return nil, err + } + + digest, err := o.Digest() + if err != nil { + return nil, err + } + + for _, s := range sigs { + existingDigest, err := s.Digest() + if err != nil { + return nil, err + } + + if digest == existingDigest { + return s, nil + } + } + + return nil, nil +}