Skip to content

Commit

Permalink
Add conflict to cosign_sign
Browse files Browse the repository at this point in the history
The default is APPEND, which matches the current behavior today. This
adds both REPLACE and SKIPSAME to match what cosign_attest does. Instead
of using predicateType as the replacement criteria, this uses the
signature layer's digest.

It should be safe to bump this before we bump terraform-publisher-apko
because the default matches the current behavior.

Signed-off-by: Jon Johnson <jon.johnson@chainguard.dev>
  • Loading branch information
jonjohnsonjr committed Oct 20, 2023
1 parent 6cc22c0 commit baad44d
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 17 deletions.
18 changes: 13 additions & 5 deletions internal/provider/resource_sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions internal/provider/resource_sign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
})
}
}
2 changes: 1 addition & 1 deletion internal/secant/attest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions internal/secant/replace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}

Expand All @@ -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
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
93 changes: 87 additions & 6 deletions internal/secant/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -71,11 +82,81 @@ 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
}

// 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
}

0 comments on commit baad44d

Please sign in to comment.