From bac7923a46c667697c4bced33480925f8f299825 Mon Sep 17 00:00:00 2001 From: Billy Lynch Date: Thu, 23 Nov 2023 13:27:06 -0700 Subject: [PATCH] Adds initial Attestor implementation. This is the initial implementation of Attestors, which uses generics to link chains components together with strict typing. To start, this adds Attestor implementations of OCI signing and v1 SLSA attestations. These Attestors are NOT wired up to the controller yet, since they don't yet support the full range of config options (and there's likely a few tweaks we need to make in order to help reuse components like signers between Attestors). --- pkg/artifacts/signable.go | 55 +++++-- pkg/artifacts/signable_test.go | 11 +- pkg/chains/formats/format.go | 9 ++ pkg/chains/formats/simple/simple.go | 15 ++ pkg/chains/formats/slsa/extract/extract.go | 22 +-- pkg/chains/formats/slsa/v1/intotoite6.go | 80 +++++++++- pkg/chains/formats/slsa/v1/intotoite6_test.go | 24 +-- .../slsa/v1/pipelinerun/pipelinerun.go | 4 +- pkg/chains/formats/slsa/v1/taskrun/taskrun.go | 4 +- .../internal/attestors/attestor_test.go | 137 ++++++++++++++++++ pkg/chains/internal/attestors/attestors.go | 117 +++++++++++++++ pkg/chains/signing.go | 7 + pkg/chains/signing/x509/x509.go | 6 + pkg/chains/storage/oci/attestation.go | 79 ++++++---- pkg/chains/storage/oci/legacy.go | 68 ++++----- pkg/chains/storage/oci/options.go | 39 ++--- pkg/chains/storage/oci/simple.go | 21 ++- pkg/chains/storage/tekton/tekton.go | 39 ++++- 18 files changed, 579 insertions(+), 158 deletions(-) create mode 100644 pkg/chains/internal/attestors/attestor_test.go create mode 100644 pkg/chains/internal/attestors/attestors.go 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