diff --git a/pkg/artifacts/signable.go b/pkg/artifacts/signable.go index 1ac9492f99..4e8016f204 100644 --- a/pkg/artifacts/signable.go +++ b/pkg/artifacts/signable.go @@ -60,6 +60,11 @@ type Signable interface { Enabled(cfg config.Config) bool } +// Extractor extracts a given type T from a Tekton object. +type Extractor[T any] interface { + Extract(ctx context.Context, obj objects.TektonObject) ([]T, error) +} + type TaskRunArtifact struct{} var _ Signable = &TaskRunArtifact{} @@ -150,7 +155,32 @@ type image struct { func (oa *OCIArtifact) ExtractObjects(ctx context.Context, obj objects.TektonObject) []interface{} { log := logging.FromContext(ctx) - objs := []interface{}{} + digests, err := oa.Extract(ctx, obj) + if err != nil { + log.Error(err) + return nil + } + + // Convert to interface + objs := []any{} + for _, d := range digests { + objs = append(objs, d) + } + return objs +} + +var ( + defaultOCI = OCIArtifact{} +) + +func ExtractOCI(ctx context.Context, obj objects.TektonObject) ([]name.Digest, error) { + return defaultOCI.Extract(ctx, obj) +} + +func (OCIArtifact) Extract(ctx context.Context, obj objects.TektonObject) ([]name.Digest, error) { + log := logging.FromContext(ctx) + + var out []name.Digest // TODO: Not applicable to PipelineRuns, should look into a better way to separate this out if tr, ok := obj.GetObject().(*v1beta1.TaskRun); ok { @@ -182,21 +212,25 @@ func (oa *OCIArtifact) ExtractObjects(ctx context.Context, obj objects.TektonObj log.Error(err) continue } - objs = append(objs, dgst) + out = append(out, dgst) } } // Now check TaskResults - resultImages := ExtractOCIImagesFromResults(ctx, obj) - objs = append(objs, resultImages...) + digests, err := extractOCIImagesFromResults(ctx, obj) + if err != nil { + log.Warnf("error extracting digests from results: %v", err) + return nil, err + } + out = append(out, digests...) - return objs + return out, nil } -func ExtractOCIImagesFromResults(ctx context.Context, obj objects.TektonObject) []interface{} { +func extractOCIImagesFromResults(ctx context.Context, obj objects.TektonObject) ([]name.Digest, error) { logger := logging.FromContext(ctx) - objs := []interface{}{} + out := []name.Digest{} extractor := structuredSignableExtractor{ uriSuffix: "IMAGE_URL", digestSuffix: "IMAGE_DIGEST", @@ -209,7 +243,7 @@ func ExtractOCIImagesFromResults(ctx context.Context, obj objects.TektonObject) continue } - objs = append(objs, dgst) + out = append(out, dgst) } // look for a comma separated list of images @@ -229,11 +263,10 @@ func ExtractOCIImagesFromResults(ctx context.Context, obj objects.TektonObject) logger.Errorf("error getting digest for img %s: %v", trimmed, err) continue } - objs = append(objs, dgst) + out = append(out, dgst) } } - - return objs + return out, nil } // ExtractSignableTargetFromResults extracts signable targets that aim to generate intoto provenance as materials within TaskRun results and store them as StructuredSignable. diff --git a/pkg/artifacts/signable_test.go b/pkg/artifacts/signable_test.go index b3181020c3..cc654a768f 100644 --- a/pkg/artifacts/signable_test.go +++ b/pkg/artifacts/signable_test.go @@ -331,16 +331,19 @@ func TestExtractOCIImagesFromResults(t *testing.T) { }, } obj := objects.NewTaskRunObject(tr) - want := []interface{}{ + want := []name.Digest{ createDigest(t, fmt.Sprintf("img1@%s", digest1)), createDigest(t, fmt.Sprintf("img2@%s", digest2)), createDigest(t, fmt.Sprintf("img3@%s", digest1)), } ctx := logtesting.TestContextWithLogger(t) - got := ExtractOCIImagesFromResults(ctx, obj) + got, err := extractOCIImagesFromResults(ctx, obj) + if err != nil { + t.Fatal(err) + } sort.Slice(got, func(i, j int) bool { - a := got[i].(name.Digest) - b := got[j].(name.Digest) + a := got[i] + b := got[j] return a.String() < b.String() }) if !cmp.Equal(got, want, ignore...) { diff --git a/pkg/chains/formats/format.go b/pkg/chains/formats/format.go index 6c75a5866a..8cd44e33b8 100644 --- a/pkg/chains/formats/format.go +++ b/pkg/chains/formats/format.go @@ -21,12 +21,21 @@ import ( ) // Payloader is an interface to generate a chains Payload from a TaskRun +// Deprecated: Use Formatter instead. type Payloader interface { CreatePayload(ctx context.Context, obj interface{}) (interface{}, error) Type() config.PayloadType Wrap() bool } +// Formatter transforms an extracted Input artifact into an Output +// artifact suitable for signing + storage. +type Formatter[Input any, Output any] interface { + // Effectively the same as CreatePayload, but using a different name so that + // this interface can coexist with Payloader. + FormatPayload(ctx context.Context, in Input) (Output, error) +} + const ( PayloadTypeTekton config.PayloadType = "tekton" PayloadTypeSimpleSigning config.PayloadType = "simplesigning" diff --git a/pkg/chains/formats/simple/simple.go b/pkg/chains/formats/simple/simple.go index 10c464f96a..b2fe104a60 100644 --- a/pkg/chains/formats/simple/simple.go +++ b/pkg/chains/formats/simple/simple.go @@ -15,6 +15,7 @@ package simple import ( "context" + "encoding/json" "fmt" "github.com/sigstore/sigstore/pkg/signature/payload" @@ -66,6 +67,20 @@ func (i SimpleContainerImage) ImageName() string { return fmt.Sprintf("%s@%s", i.Critical.Identity.DockerReference, i.Critical.Image.DockerManifestDigest) } +func (i SimpleContainerImage) MarshalBinary() ([]byte, error) { + return json.Marshal(i) +} + func (i *SimpleSigning) Type() config.PayloadType { return formats.PayloadTypeSimpleSigning } + +var ( + _ formats.Formatter[name.Digest, SimpleContainerImage] = &SimpleSigningPayloader{} +) + +type SimpleSigningPayloader SimpleSigning + +func (SimpleSigningPayloader) FormatPayload(_ context.Context, v name.Digest) (SimpleContainerImage, error) { + return NewSimpleStruct(v), nil +} diff --git a/pkg/chains/formats/slsa/extract/extract.go b/pkg/chains/formats/slsa/extract/extract.go index 7a2d093c87..f647d2fbce 100644 --- a/pkg/chains/formats/slsa/extract/extract.go +++ b/pkg/chains/formats/slsa/extract/extract.go @@ -21,7 +21,6 @@ import ( "fmt" "strings" - "github.com/google/go-containerregistry/pkg/name" intoto "github.com/in-toto/in-toto-golang/in_toto" "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" "github.com/tektoncd/chains/internal/backport" @@ -95,16 +94,17 @@ func subjectsFromTektonObject(ctx context.Context, obj objects.TektonObject) []i logger := logging.FromContext(ctx) var subjects []intoto.Subject - imgs := artifacts.ExtractOCIImagesFromResults(ctx, obj) - for _, i := range imgs { - if d, ok := i.(name.Digest); ok { - subjects = artifact.AppendSubjects(subjects, intoto.Subject{ - Name: d.Repository.Name(), - Digest: common.DigestSet{ - "sha256": strings.TrimPrefix(d.DigestStr(), "sha256:"), - }, - }) - } + imgs, err := artifacts.ExtractOCI(ctx, obj) + if err != nil { + logger.Warnf("error extracting OCI artifacts: %v", err) + } + for _, d := range imgs { + subjects = artifact.AppendSubjects(subjects, intoto.Subject{ + Name: d.Repository.Name(), + Digest: common.DigestSet{ + "sha256": strings.TrimPrefix(d.DigestStr(), "sha256:"), + }, + }) } sts := artifacts.ExtractSignableTargetFromResults(ctx, obj) diff --git a/pkg/chains/formats/slsa/v1/intotoite6.go b/pkg/chains/formats/slsa/v1/intotoite6.go index 4ab3c8d0bf..d84e181262 100644 --- a/pkg/chains/formats/slsa/v1/intotoite6.go +++ b/pkg/chains/formats/slsa/v1/intotoite6.go @@ -18,8 +18,10 @@ package v1 import ( "context" + "encoding/json" "fmt" + "github.com/in-toto/in-toto-golang/in_toto" "github.com/tektoncd/chains/pkg/chains/formats" "github.com/tektoncd/chains/pkg/chains/formats/slsa/internal/slsaconfig" "github.com/tektoncd/chains/pkg/chains/formats/slsa/v1/pipelinerun" @@ -34,21 +36,57 @@ const ( ) func init() { - formats.RegisterPayloader(PayloadTypeInTotoIte6, NewFormatter) - formats.RegisterPayloader(PayloadTypeSlsav1, NewFormatter) + formats.RegisterPayloader(PayloadTypeInTotoIte6, NewPayloader) + formats.RegisterPayloader(PayloadTypeSlsav1, NewPayloader) } type InTotoIte6 struct { slsaConfig *slsaconfig.SlsaConfig } -func NewFormatter(cfg config.Config) (formats.Payloader, error) { +func NewPayloader(cfg config.Config) (formats.Payloader, error) { + return NewPayloaderFromConfig(cfg), nil +} + +func NewPayloaderFromConfig(cfg config.Config) *InTotoIte6 { + opts := []Option{ + WithBuilderID(cfg.Builder.ID), + WithDeepInspection(cfg.Artifacts.PipelineRuns.DeepInspectionEnabled), + } + return NewFormatter(opts...) +} + +type options struct { + builderID string + deepInspection bool +} + +type Option func(*options) + +func WithDeepInspection(enabled bool) Option { + return func(o *options) { + o.deepInspection = enabled + } +} + +func WithBuilderID(id string) Option { + return func(o *options) { + o.builderID = id + } +} + +func NewFormatter(opts ...Option) *InTotoIte6 { + o := &options{} + for _, f := range opts { + f(o) + } + return &InTotoIte6{ slsaConfig: &slsaconfig.SlsaConfig{ - BuilderID: cfg.Builder.ID, - DeepInspectionEnabled: cfg.Artifacts.PipelineRuns.DeepInspectionEnabled, + BuilderID: o.builderID, + DeepInspectionEnabled: o.deepInspection, }, - }, nil + } } func (i *InTotoIte6) Wrap() bool { @@ -66,6 +104,36 @@ func (i *InTotoIte6) CreatePayload(ctx context.Context, obj interface{}) (interf } } +func (i *InTotoIte6) FormatPayload(ctx context.Context, obj objects.TektonObject) (*ProvenanceStatement, error) { + var ( + s *in_toto.ProvenanceStatement + err error + ) + + switch v := obj.(type) { + case *objects.TaskRunObject: + s, err = taskrun.GenerateAttestation(ctx, v, i.slsaConfig) + case *objects.PipelineRunObject: + s, err = pipelinerun.GenerateAttestation(ctx, v, i.slsaConfig) + default: + return nil, fmt.Errorf("intoto does not support type: %s", v) + } + + if err != nil { + return nil, err + } + // Wrap output in BinaryMarshaller so we know how to format this. + out := ProvenanceStatement(*s) + return &out, nil + +} + func (i *InTotoIte6) Type() config.PayloadType { return formats.PayloadTypeSlsav1 } + +type ProvenanceStatement in_toto.ProvenanceStatement + +func (s ProvenanceStatement) MarshalBinary() ([]byte, error) { + return json.Marshal(s) +} diff --git a/pkg/chains/formats/slsa/v1/intotoite6_test.go b/pkg/chains/formats/slsa/v1/intotoite6_test.go index a61bf2489b..307f19ff2f 100644 --- a/pkg/chains/formats/slsa/v1/intotoite6_test.go +++ b/pkg/chains/formats/slsa/v1/intotoite6_test.go @@ -54,7 +54,7 @@ func TestTaskRunCreatePayload1(t *testing.T) { ID: "test_builder-1", }, } - expected := in_toto.ProvenanceStatement{ + expected := &in_toto.ProvenanceStatement{ StatementHeader: in_toto.StatementHeader{ Type: in_toto.StatementInTotoV01, PredicateType: slsa.PredicateSLSAProvenance, @@ -133,7 +133,7 @@ func TestTaskRunCreatePayload1(t *testing.T) { }, }, } - i, _ := NewFormatter(cfg) + i, _ := NewPayloader(cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) @@ -158,7 +158,7 @@ func TestPipelineRunCreatePayload(t *testing.T) { ID: "test_builder-1", }, } - expected := in_toto.ProvenanceStatement{ + expected := &in_toto.ProvenanceStatement{ StatementHeader: in_toto.StatementHeader{ Type: in_toto.StatementInTotoV01, PredicateType: slsa.PredicateSLSAProvenance, @@ -359,7 +359,7 @@ func TestPipelineRunCreatePayload(t *testing.T) { pro.AppendTaskRun(tr1) pro.AppendTaskRun(tr2) - i, _ := NewFormatter(cfg) + i, _ := NewPayloader(cfg) got, err := i.CreatePayload(ctx, pro) if err != nil { @@ -382,7 +382,7 @@ func TestPipelineRunCreatePayloadChildRefs(t *testing.T) { ID: "test_builder-1", }, } - expected := in_toto.ProvenanceStatement{ + expected := &in_toto.ProvenanceStatement{ StatementHeader: in_toto.StatementHeader{ Type: in_toto.StatementInTotoV01, PredicateType: slsa.PredicateSLSAProvenance, @@ -577,7 +577,7 @@ func TestPipelineRunCreatePayloadChildRefs(t *testing.T) { pro.AppendTaskRun(tr1) pro.AppendTaskRun(tr2) - i, _ := NewFormatter(cfg) + i, _ := NewPayloader(cfg) got, err := i.CreatePayload(ctx, pro) if err != nil { t.Errorf("unexpected error: %s", err.Error()) @@ -600,7 +600,7 @@ func TestTaskRunCreatePayload2(t *testing.T) { ID: "test_builder-2", }, } - expected := in_toto.ProvenanceStatement{ + expected := &in_toto.ProvenanceStatement{ StatementHeader: in_toto.StatementHeader{ Type: in_toto.StatementInTotoV01, PredicateType: slsa.PredicateSLSAProvenance, @@ -652,7 +652,7 @@ func TestTaskRunCreatePayload2(t *testing.T) { }, }, } - i, _ := NewFormatter(cfg) + i, _ := NewPayloader(cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) if err != nil { @@ -676,7 +676,7 @@ func TestMultipleSubjects(t *testing.T) { ID: "test_builder-multiple", }, } - expected := in_toto.ProvenanceStatement{ + expected := &in_toto.ProvenanceStatement{ StatementHeader: in_toto.StatementHeader{ Type: in_toto.StatementInTotoV01, PredicateType: slsa.PredicateSLSAProvenance, @@ -723,7 +723,7 @@ func TestMultipleSubjects(t *testing.T) { }, } - i, _ := NewFormatter(cfg) + i, _ := NewPayloader(cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) if err != nil { t.Errorf("unexpected error: %s", err.Error()) @@ -740,7 +740,7 @@ func TestNewFormatter(t *testing.T) { ID: "testid", }, } - f, err := NewFormatter(cfg) + f, err := NewPayloader(cfg) if f == nil { t.Error("Failed to create formatter") } @@ -758,7 +758,7 @@ func TestCreatePayloadError(t *testing.T) { ID: "testid", }, } - f, _ := NewFormatter(cfg) + f, _ := NewPayloader(cfg) t.Run("Invalid type", func(t *testing.T) { p, err := f.CreatePayload(ctx, "not a task ref") diff --git a/pkg/chains/formats/slsa/v1/pipelinerun/pipelinerun.go b/pkg/chains/formats/slsa/v1/pipelinerun/pipelinerun.go index e652111e5f..05a7e3aa3d 100644 --- a/pkg/chains/formats/slsa/v1/pipelinerun/pipelinerun.go +++ b/pkg/chains/formats/slsa/v1/pipelinerun/pipelinerun.go @@ -47,14 +47,14 @@ type TaskAttestation struct { Results []v1beta1.TaskRunResult `json:"results,omitempty"` } -func GenerateAttestation(ctx context.Context, pro *objects.PipelineRunObject, slsaConfig *slsaconfig.SlsaConfig) (interface{}, error) { +func GenerateAttestation(ctx context.Context, pro *objects.PipelineRunObject, slsaConfig *slsaconfig.SlsaConfig) (*intoto.ProvenanceStatement, error) { subjects := extract.SubjectDigests(ctx, pro, slsaConfig) mat, err := material.PipelineMaterials(ctx, pro, slsaConfig) if err != nil { return nil, err } - att := intoto.ProvenanceStatement{ + att := &intoto.ProvenanceStatement{ StatementHeader: intoto.StatementHeader{ Type: intoto.StatementInTotoV01, PredicateType: slsa.PredicateSLSAProvenance, diff --git a/pkg/chains/formats/slsa/v1/taskrun/taskrun.go b/pkg/chains/formats/slsa/v1/taskrun/taskrun.go index 36f185a3ea..9a10cbce49 100644 --- a/pkg/chains/formats/slsa/v1/taskrun/taskrun.go +++ b/pkg/chains/formats/slsa/v1/taskrun/taskrun.go @@ -27,14 +27,14 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" ) -func GenerateAttestation(ctx context.Context, tro *objects.TaskRunObject, slsaConfig *slsaconfig.SlsaConfig) (interface{}, error) { +func GenerateAttestation(ctx context.Context, tro *objects.TaskRunObject, slsaConfig *slsaconfig.SlsaConfig) (*intoto.ProvenanceStatement, error) { subjects := extract.SubjectDigests(ctx, tro, slsaConfig) mat, err := material.TaskMaterials(ctx, tro) if err != nil { return nil, err } - att := intoto.ProvenanceStatement{ + att := &intoto.ProvenanceStatement{ StatementHeader: intoto.StatementHeader{ Type: intoto.StatementInTotoV01, PredicateType: slsa.PredicateSLSAProvenance, diff --git a/pkg/chains/internal/attestors/attestor_test.go b/pkg/chains/internal/attestors/attestor_test.go new file mode 100644 index 0000000000..08b33efcf3 --- /dev/null +++ b/pkg/chains/internal/attestors/attestor_test.go @@ -0,0 +1,137 @@ +package attestors + +import ( + "crypto" + "fmt" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/sigstore/cosign/v2/pkg/cosign" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/tektoncd/chains/pkg/chains/formats/simple" + v1 "github.com/tektoncd/chains/pkg/chains/formats/slsa/v1" + "github.com/tektoncd/chains/pkg/chains/objects" + "github.com/tektoncd/chains/pkg/chains/signing" + "github.com/tektoncd/chains/pkg/chains/signing/x509" + "github.com/tektoncd/chains/pkg/chains/storage/oci" + "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + logtest "knative.dev/pkg/logging/testing" +) + +func TestOCIAttestor(t *testing.T) { + digest := setupRegistry(t) + + // Create local signer using randomly generated key. + sv := newSignerVerifier(t) + signer := &x509.Signer{SignerVerifier: sv} + + storer, err := oci.NewSimpleStorer() + if err != nil { + t.Fatal(err) + } + + att := &Attestor[name.Digest, simple.SimpleContainerImage]{ + payloader: simple.SimpleSigningPayloader{}, + signer: signer, + storer: storer, + } + ctx := logtest.TestContextWithLogger(t) + if _, err := att.Attest(ctx, nil, digest); err != nil { + t.Error(err) + } + + // Verify signature to make sure it was pushed properly. + if _, _, err := cosign.VerifyImageSignatures(ctx, digest, &cosign.CheckOpts{ + SigVerifier: sv, + IgnoreTlog: true, + }); err != nil { + t.Error(err) + } +} + +func setupRegistry(t *testing.T) name.Digest { + t.Helper() + + reg := httptest.NewServer(registry.New()) + t.Cleanup(reg.Close) + + // Push an image to the local registry. + ref, err := name.ParseReference(fmt.Sprintf("%s/foo", strings.TrimPrefix(reg.URL, "http://"))) + if err != nil { + t.Fatal(err) + } + if err := remote.Put(ref, empty.Image); err != nil { + t.Fatal(err) + } + h, err := empty.Image.Digest() + if err != nil { + t.Fatal(err) + } + return ref.Context().Digest(h.String()) +} + +func newSignerVerifier(t *testing.T) signature.SignerVerifier { + t.Helper() + + priv, err := cosign.GeneratePrivateKey() + if err != nil { + t.Fatalf("error generating keypair: %v", err) + } + sv, err := signature.LoadECDSASignerVerifier(priv, crypto.SHA256) + if err != nil { + t.Fatal(err) + } + return sv +} + +func TestSLSAAttestor(t *testing.T) { + digest := setupRegistry(t) + + // Create local signer using randomly generated key. + sv := newSignerVerifier(t) + signer := &x509.Signer{SignerVerifier: sv} + wrapped, err := signing.Wrap(signer) + if err != nil { + t.Fatal(err) + } + + storer, err := oci.NewAttestationStorer[*v1.ProvenanceStatement]() + if err != nil { + t.Fatal(err) + } + + tr := &v1beta1.TaskRun{ + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{{ + Name: "IMAGES", + Value: *v1beta1.NewArrayOrString(digest.String()), + }}, + }, + }, + } + obj := objects.NewTaskRunObject(tr) + + att := &Attestor[objects.TektonObject, *v1.ProvenanceStatement]{ + payloader: v1.NewFormatter(), + signer: wrapped, + storer: storer, + } + ctx := logtest.TestContextWithLogger(t) + if _, err := att.Attest(ctx, obj, obj); err != nil { + t.Error(err) + } + + // Verify attestation to make sure it was stored properly. + if _, _, err := cosign.VerifyImageAttestations(ctx, digest, &cosign.CheckOpts{ + SigVerifier: sv, + IgnoreTlog: true, + }); err != nil { + t.Error(err) + } +} diff --git a/pkg/chains/internal/attestors/attestors.go b/pkg/chains/internal/attestors/attestors.go new file mode 100644 index 0000000000..ce65b5ced6 --- /dev/null +++ b/pkg/chains/internal/attestors/attestors.go @@ -0,0 +1,117 @@ +package attestors + +import ( + "bytes" + "context" + "encoding" + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/tektoncd/chains/pkg/chains" + "github.com/tektoncd/chains/pkg/chains/formats" + "github.com/tektoncd/chains/pkg/chains/formats/simple" + v1 "github.com/tektoncd/chains/pkg/chains/formats/slsa/v1" + "github.com/tektoncd/chains/pkg/chains/objects" + "github.com/tektoncd/chains/pkg/chains/signing" + "github.com/tektoncd/chains/pkg/chains/storage/api" + "github.com/tektoncd/chains/pkg/chains/storage/oci" + "github.com/tektoncd/chains/pkg/config" +) + +type AttestorHandler[Input any] interface { + Attest(context.Context, objects.TektonObject, Input) error +} + +type Attestor[Input any, Output encoding.BinaryMarshaler] struct { + payloader formats.Formatter[Input, Output] + signer signing.Signer + storer api.Storer[Input, Output] +} + +// Handler takes an input object -> creates, signs, and stores its attestation. +func (a *Attestor[Input, Output]) Attest(ctx context.Context, obj objects.TektonObject, in Input) (*api.StoreResponse, error) { + out, err := a.payloader.FormatPayload(ctx, in) + if err != nil { + return nil, fmt.Errorf("error creating attestation payload: %w", err) + } + + b, err := out.MarshalBinary() + if err != nil { + return nil, fmt.Errorf("error marshalling payload: %w", err) + } + + sig, err := a.signer.SignMessage(bytes.NewReader(b)) + if err != nil { + return nil, fmt.Errorf("error signing payload: %w", err) + } + req := &api.StoreRequest[Input, Output]{ + Object: obj, + Artifact: in, + Payload: out, + Bundle: &signing.Bundle{ + Content: b, + Signature: sig, + Cert: []byte(a.signer.Cert()), + Chain: []byte(a.signer.Chain()), + }, + } + return a.storer.Store(ctx, req) +} + +func NewContainerSigner(ctx context.Context, cfg config.Config) (*Attestor[name.Digest, simple.SimpleContainerImage], error) { + signer, err := chains.NewSignerFromConfig(ctx, "", cfg) + if err != nil { + return nil, err + } + + var opts []oci.Option + if repo := cfg.Storage.OCI.Repository; repo != "" { + r, err := name.NewRepository(repo) + if err != nil { + return nil, fmt.Errorf("error parsing OCI repo name: %w", err) + } + opts = append(opts, oci.WithTargetRepository(r)) + } + + storer, err := oci.NewSimpleStorer(opts...) + if err != nil { + return nil, err + } + + return &Attestor[name.Digest, simple.SimpleContainerImage]{ + payloader: simple.SimpleSigningPayloader{}, + signer: signer, + storer: storer, + }, nil +} + +func NewProvenanceSigner(ctx context.Context, cfg config.Config) (*Attestor[objects.TektonObject, *v1.ProvenanceStatement], error) { + signer, err := chains.NewSignerFromConfig(ctx, "", cfg) + if err != nil { + return nil, err + } + wrapped, err := signing.Wrap(signer) + if err != nil { + return nil, err + } + + var opts []oci.Option + if repo := cfg.Storage.OCI.Repository; repo != "" { + r, err := name.NewRepository(repo) + if err != nil { + return nil, fmt.Errorf("error parsing OCI repo name: %w", err) + } + opts = append(opts, oci.WithTargetRepository(r)) + } + storer, err := oci.NewAttestationStorer[*v1.ProvenanceStatement](opts...) + if err != nil { + return nil, err + } + + return &Attestor[objects.TektonObject, *v1.ProvenanceStatement]{ + payloader: v1.NewPayloaderFromConfig(cfg), + signer: wrapped, + // TODO: add support for other storage options. + storer: storer, + }, nil +} diff --git a/pkg/chains/signing.go b/pkg/chains/signing.go index 6a28b5e349..b4d55aefdc 100644 --- a/pkg/chains/signing.go +++ b/pkg/chains/signing.go @@ -46,6 +46,13 @@ type ObjectSigner struct { Pipelineclientset versioned.Interface } +func NewSignerFromConfig(ctx context.Context, sp string, cfg config.Config) (signing.Signer, error) { + if cfg.Signers.KMS.KMSRef != "" { + return kms.NewSigner(ctx, cfg.Signers.KMS) + } + return x509.NewSigner(ctx, sp, cfg) +} + func allSigners(ctx context.Context, sp string, cfg config.Config) map[string]signing.Signer { l := logging.FromContext(ctx) all := map[string]signing.Signer{} diff --git a/pkg/chains/signing/x509/x509.go b/pkg/chains/signing/x509/x509.go index ce1c8d6777..6a6db1c2e5 100644 --- a/pkg/chains/signing/x509/x509.go +++ b/pkg/chains/signing/x509/x509.go @@ -42,6 +42,9 @@ import ( const ( defaultOIDCClientID = "sigstore" + + // SecretPath contains the path to the secrets volume that is mounted in. + defaultSecretPath = "/etc/signing-secrets" ) // Signer exposes methods to sign payloads. @@ -53,6 +56,9 @@ type Signer struct { // NewSigner returns a configured Signer func NewSigner(ctx context.Context, secretPath string, cfg config.Config) (*Signer, error) { + if secretPath == "" { + secretPath = defaultSecretPath + } x509PrivateKeyPath := filepath.Join(secretPath, "x509.pem") cosignPrivateKeypath := filepath.Join(secretPath, "cosign.key") diff --git a/pkg/chains/storage/oci/attestation.go b/pkg/chains/storage/oci/attestation.go index 0fd6709f33..bd615a6f9e 100644 --- a/pkg/chains/storage/oci/attestation.go +++ b/pkg/chains/storage/oci/attestation.go @@ -19,22 +19,26 @@ import ( "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/in-toto/in-toto-golang/in_toto" + "github.com/hashicorp/go-multierror" "github.com/pkg/errors" + "github.com/sigstore/cosign/v2/pkg/oci" "github.com/sigstore/cosign/v2/pkg/oci/mutate" ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote" "github.com/sigstore/cosign/v2/pkg/oci/static" "github.com/sigstore/cosign/v2/pkg/types" + "github.com/tektoncd/chains/pkg/artifacts" + v1 "github.com/tektoncd/chains/pkg/chains/formats/slsa/v1" + "github.com/tektoncd/chains/pkg/chains/objects" "github.com/tektoncd/chains/pkg/chains/storage/api" "knative.dev/pkg/logging" ) var ( - _ api.Storer[name.Digest, in_toto.Statement] = &AttestationStorer{} + _ api.Storer[objects.TektonObject, v1.ProvenanceStatement] = &AttestationStorer[v1.ProvenanceStatement]{} ) // AttestationStorer stores in-toto Attestation payloads in OCI registries. -type AttestationStorer struct { +type AttestationStorer[T any] struct { // repo configures the repo where data should be stored. // If empty, the repo is inferred from the Artifact. repo *name.Repository @@ -42,27 +46,19 @@ type AttestationStorer struct { remoteOpts []remote.Option } -func NewAttestationStorer(opts ...AttestationStorerOption) (*AttestationStorer, error) { - s := &AttestationStorer{} - for _, o := range opts { - if err := o.applyAttestationStorer(s); err != nil { - return nil, err - } +func NewAttestationStorer[T any](opts ...Option) (*AttestationStorer[T], error) { + o := &ociOption{} + for _, f := range opts { + f(o) } - return s, nil + return &AttestationStorer[T]{ + repo: o.repo, + remoteOpts: o.remote, + }, nil } -func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[name.Digest, in_toto.Statement]) (*api.StoreResponse, error) { - logger := logging.FromContext(ctx) - - repo := req.Artifact.Repository - if s.repo != nil { - repo = *s.repo - } - se, err := ociremote.SignedEntity(req.Artifact, ociremote.WithRemoteOptions(s.remoteOpts...)) - if err != nil { - return nil, errors.Wrap(err, "getting signed image") - } +func (s *AttestationStorer[T]) Store(ctx context.Context, req *api.StoreRequest[objects.TektonObject, T]) (*api.StoreResponse, error) { + log := logging.FromContext(ctx) // Create the new attestation for this entity. attOpts := []static.Option{static.WithLayerMediaType(types.DssePayloadType)} @@ -73,16 +69,47 @@ func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[nam if err != nil { return nil, err } - newImage, err := mutate.AttachAttestationToEntity(se, att) + + // Store attestation to all images present in object. + images, err := artifacts.ExtractOCI(ctx, req.Object) if err != nil { return nil, err } - // Publish the signatures associated with this entity - if err := ociremote.WriteAttestations(repo, newImage, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil { - return nil, err + var merr error + for _, img := range images { + log.Infof("storing attestation in %s", img) + if err := s.storeImage(ctx, img, att); err != nil { + merr = multierror.Append(merr, err) + } + } + if merr != nil { + return nil, merr } - logger.Infof("Successfully uploaded attestation for %s", req.Artifact.String()) return &api.StoreResponse{}, nil } + +func (s *AttestationStorer[T]) storeImage(ctx context.Context, img name.Digest, att oci.Signature) error { + logger := logging.FromContext(ctx) + repo := img.Repository + if s.repo != nil { + repo = *s.repo + } + se, err := ociremote.SignedEntity(img, ociremote.WithRemoteOptions(s.remoteOpts...)) + if err != nil { + return errors.Wrap(err, "getting signed image") + } + + newImage, err := mutate.AttachAttestationToEntity(se, att) + if err != nil { + return err + } + + // Publish the signatures associated with this entity + if err := ociremote.WriteAttestations(repo, newImage, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil { + return err + } + logger.Infof("Successfully uploaded attestation for %s", img.String()) + return nil +} diff --git a/pkg/chains/storage/oci/legacy.go b/pkg/chains/storage/oci/legacy.go index 64717d2772..ca9d6de342 100644 --- a/pkg/chains/storage/oci/legacy.go +++ b/pkg/chains/storage/oci/legacy.go @@ -106,7 +106,7 @@ func (b *Backend) StorePayload(ctx context.Context, obj objects.TektonObject, ra return nil } - return b.uploadAttestation(ctx, attestation, signature, storageOpts, auth) + return b.uploadAttestation(ctx, obj, attestation, signature, storageOpts, auth) } // Fallback in case unsupported payload format is used or the deprecated "tekton" format @@ -130,7 +130,7 @@ func (b *Backend) uploadSignature(ctx context.Context, format simple.SimpleConta return errors.Wrapf(err, "getting storage repo for sub %s", imageName) } - store, err := NewSimpleStorerFromConfig(WithTargetRepository(repo)) + store, err := NewSimpleStorer(WithTargetRepository(repo)) if err != nil { return err } @@ -152,44 +152,31 @@ func (b *Backend) uploadSignature(ctx context.Context, format simple.SimpleConta return nil } -func (b *Backend) uploadAttestation(ctx context.Context, attestation in_toto.Statement, signature string, storageOpts config.StorageOpts, remoteOpts ...remote.Option) error { +func (b *Backend) uploadAttestation(ctx context.Context, obj objects.TektonObject, attestation in_toto.Statement, signature string, storageOpts config.StorageOpts, remoteOpts ...remote.Option) error { logger := logging.FromContext(ctx) // upload an attestation for each subject logger.Info("Starting to upload attestations to OCI ...") - for _, subj := range attestation.Subject { - imageName := fmt.Sprintf("%s@sha256:%s", subj.Name, subj.Digest["sha256"]) - logger.Infof("Starting attestation upload to OCI for %s...", imageName) - ref, err := name.NewDigest(imageName) - if err != nil { - return errors.Wrapf(err, "getting digest for subj %s", imageName) - } - - repo, err := newRepo(b.cfg, ref) - if err != nil { - return errors.Wrapf(err, "getting storage repo for sub %s", imageName) - } - - store, err := NewAttestationStorer(WithTargetRepository(repo)) - if err != nil { - return err - } - // TODO: make these creation opts. - store.remoteOpts = remoteOpts - if _, err := store.Store(ctx, &api.StoreRequest[name.Digest, in_toto.Statement]{ - Object: nil, - Artifact: ref, - Payload: attestation, - Bundle: &signing.Bundle{ - Content: nil, - Signature: []byte(signature), - Cert: []byte(storageOpts.Cert), - Chain: []byte(storageOpts.Chain), - }, - }); err != nil { - return err - } + store, err := NewAttestationStorer[in_toto.Statement]() + if err != nil { + return err + } + // TODO: make these creation opts. + store.remoteOpts = remoteOpts + if _, err := store.Store(ctx, &api.StoreRequest[objects.TektonObject, in_toto.Statement]{ + Object: obj, + Artifact: obj, + Payload: attestation, + Bundle: &signing.Bundle{ + Content: nil, + Signature: []byte(signature), + Cert: []byte(storageOpts.Cert), + Chain: []byte(storageOpts.Chain), + }, + }); err != nil { + return err } + return nil } @@ -271,14 +258,13 @@ func (b *Backend) RetrievePayloads(ctx context.Context, obj objects.TektonObject func (b *Backend) RetrieveArtifact(ctx context.Context, obj objects.TektonObject, opts config.StorageOpts) (map[string]oci.SignedImage, error) { // Given the TaskRun, retrieve the OCI images. - images := artifacts.ExtractOCIImagesFromResults(ctx, obj) + images, err := artifacts.ExtractOCI(ctx, obj) + if err != nil { + return nil, err + } m := make(map[string]oci.SignedImage) - for _, image := range images { - ref, ok := image.(name.Digest) - if !ok { - return nil, errors.New("error parsing image") - } + for _, ref := range images { img, err := ociremote.SignedImage(ref) if err != nil { return nil, err diff --git a/pkg/chains/storage/oci/options.go b/pkg/chains/storage/oci/options.go index c905e7699c..879cb73070 100644 --- a/pkg/chains/storage/oci/options.go +++ b/pkg/chains/storage/oci/options.go @@ -14,41 +14,22 @@ package oci -import "github.com/google/go-containerregistry/pkg/name" +import ( + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) // Option provides a config option compatible with all OCI storers. -type Option interface { - AttestationStorerOption - SimpleStorerOption -} - -// AttestationStorerOption provides a config option compatible with AttestationStorer. -type AttestationStorerOption interface { - applyAttestationStorer(s *AttestationStorer) error -} +type Option func(o *ociOption) -// SimpleStorerOption provides a config option compatible with SimpleStorer. -type SimpleStorerOption interface { - applySimpleStorer(s *SimpleStorer) error +type ociOption struct { + repo *name.Repository + remote []remote.Option } // WithTargetRepository configures the target repository where objects will be stored. func WithTargetRepository(repo name.Repository) Option { - return &targetRepoOption{ - repo: repo, + return func(o *ociOption) { + o.repo = &repo } } - -type targetRepoOption struct { - repo name.Repository -} - -func (o *targetRepoOption) applyAttestationStorer(s *AttestationStorer) error { - s.repo = &o.repo - return nil -} - -func (o *targetRepoOption) applySimpleStorer(s *SimpleStorer) error { - s.repo = &o.repo - return nil -} diff --git a/pkg/chains/storage/oci/simple.go b/pkg/chains/storage/oci/simple.go index 8cb3c8668f..366a15265e 100644 --- a/pkg/chains/storage/oci/simple.go +++ b/pkg/chains/storage/oci/simple.go @@ -42,19 +42,20 @@ var ( _ api.Storer[name.Digest, simple.SimpleContainerImage] = &SimpleStorer{} ) -func NewSimpleStorerFromConfig(opts ...SimpleStorerOption) (*SimpleStorer, error) { - s := &SimpleStorer{} - for _, o := range opts { - if err := o.applySimpleStorer(s); err != nil { - return nil, err - } +func NewSimpleStorer(opts ...Option) (*SimpleStorer, error) { + o := &ociOption{} + for _, f := range opts { + f(o) } - return s, nil + return &SimpleStorer{ + repo: o.repo, + remoteOpts: o.remote, + }, nil } func (s *SimpleStorer) Store(ctx context.Context, req *api.StoreRequest[name.Digest, simple.SimpleContainerImage]) (*api.StoreResponse, error) { logger := logging.FromContext(ctx).With("image", req.Artifact.String()) - logger.Info("Uploading signature") + logger.Info("Uploading signature", req.Artifact) se, err := ociremote.SignedEntity(req.Artifact, ociremote.WithRemoteOptions(s.remoteOpts...)) if err != nil { @@ -77,6 +78,10 @@ func (s *SimpleStorer) Store(ctx context.Context, req *api.StoreRequest[name.Dig return nil, err } + logger.Info("artifact: ", req.Artifact) + logger.Info("repo: ", req.Artifact.Repository) + logger.Info("cfg: ", s.repo != nil, s.repo) + repo := req.Artifact.Repository if s.repo != nil { repo = *s.repo diff --git a/pkg/chains/storage/tekton/tekton.go b/pkg/chains/storage/tekton/tekton.go index 0849032382..36b4630eea 100644 --- a/pkg/chains/storage/tekton/tekton.go +++ b/pkg/chains/storage/tekton/tekton.go @@ -18,7 +18,7 @@ import ( "encoding/base64" "fmt" - "github.com/in-toto/in-toto-golang/in_toto" + v1 "github.com/tektoncd/chains/pkg/chains/formats/slsa/v1" "github.com/tektoncd/chains/pkg/chains/objects" "github.com/tektoncd/chains/pkg/chains/signing" "github.com/tektoncd/chains/pkg/chains/storage/api" @@ -55,11 +55,11 @@ func NewStorageBackend(ps versioned.Interface) *Backend { func (b *Backend) StorePayload(ctx context.Context, obj objects.TektonObject, rawPayload []byte, signature string, opts config.StorageOpts) error { logger := logging.FromContext(ctx) - store := &Storer{ + store := &Storer[*v1.ProvenanceStatement]{ client: b.pipelineclientset, key: opts.ShortKey, } - if _, err := store.Store(ctx, &api.StoreRequest[objects.TektonObject, *in_toto.Statement]{ + if _, err := store.Store(ctx, &api.StoreRequest[objects.TektonObject, *v1.ProvenanceStatement]{ Object: obj, Artifact: obj, // We don't actually use payload - we store the raw bundle values directly. @@ -146,18 +146,45 @@ func payloadName(opts config.StorageOpts) string { return fmt.Sprintf(PayloadAnnotationFormat, opts.ShortKey) } -type Storer struct { +// Storer stores attestation information in Tekton objects. +// T represents any attestation output type - the Tekton Storer +// does not not use this value meaningfully (only the signature is stored), +// so this effectively allows any attestation type to be stored. +type Storer[T any] struct { client versioned.Interface // optional key override. If not specified, the UID of the object is used. key string } var ( - _ api.Storer[objects.TektonObject, *in_toto.Statement] = &Storer{} + _ api.Storer[objects.TektonObject, any] = &Storer[any]{} ) +func NewStorer[T any](client versioned.Interface, opts ...StorerOption) *Storer[T] { + o := &storerOpts{} + for _, f := range opts { + f(o) + } + return &Storer[T]{ + client: client, + key: o.key, + } +} + +type storerOpts struct { + key string +} + +type StorerOption func(*storerOpts) + +func WithKey(key string) StorerOption { + return func(s *storerOpts) { + s.key = key + } +} + // Store stores the statement in the TaskRun metadata as an annotation. -func (s *Storer) Store(ctx context.Context, req *api.StoreRequest[objects.TektonObject, *in_toto.Statement]) (*api.StoreResponse, error) { +func (s *Storer[T]) Store(ctx context.Context, req *api.StoreRequest[objects.TektonObject, T]) (*api.StoreResponse, error) { logger := logging.FromContext(ctx) obj := req.Object