From 2166414a2d17165e79de3481eaef33e93bbd72e0 Mon Sep 17 00:00:00 2001 From: Luiz Carvalho Date: Mon, 27 Feb 2023 16:40:25 -0500 Subject: [PATCH] Add support for signing SBOMs The idea is a TaskRun produces an unsigned SBOM and pushes it to the OCI registry. The reference to the SBOM is then added to the result IMAGE_SBOM_URL. When Chains sees this result, it downloads the SBOM from the OCI registry, signs it, then adds to the image attestations with an SBOM-specific predicate. IMAGE_SBOM_FORMAT is used to specify the predicate to list in the attestation. This is also possible when using the IMAGES result, and the corresponding SBOMS result. Both must have the exact number of items. When using the SBOMS result, SBOMS_FORMAT is also required to specify the SBOM format for all the items in SBOMS. Signed-off-by: Luiz Carvalho --- docs/config.md | 19 +- pkg/artifacts/signable.go | 153 ++++++- pkg/artifacts/signable_test.go | 432 ++++++++++++++++++ pkg/chains/formats/format.go | 3 +- pkg/chains/formats/sbom/sbom.go | 106 +++++ pkg/chains/formats/sbom/sbom_test.go | 167 +++++++ pkg/chains/formats/simple/simple.go | 3 +- pkg/chains/formats/simple/simple_test.go | 4 +- pkg/chains/formats/slsa/v1/intotoite6.go | 8 +- pkg/chains/formats/slsa/v1/intotoite6_test.go | 12 +- pkg/chains/formats/slsa/v2/slsav2.go | 3 +- pkg/chains/formats/slsa/v2/slsav2_test.go | 8 +- pkg/chains/objects/objects.go | 50 ++ pkg/chains/objects/objects_test.go | 33 ++ pkg/chains/signing.go | 6 +- pkg/config/config.go | 15 + pkg/config/store_test.go | 147 ++++++ pkg/reconciler/pipelinerun/controller.go | 1 + pkg/reconciler/taskrun/controller.go | 1 + 19 files changed, 1147 insertions(+), 24 deletions(-) create mode 100644 pkg/chains/formats/sbom/sbom.go create mode 100644 pkg/chains/formats/sbom/sbom_test.go diff --git a/docs/config.md b/docs/config.md index 18f797641f..c8eba7085c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -33,9 +33,18 @@ The list of images can be separated by commas or by newlines. value: img1@sha256:digest1, img2@sha256:digest2 ``` -When processing a `TaskRun`, Chains will parse through the list, then sign and attest each image. +Chains also suports signing SBOMs. If using the `IMAGE_URL` and `IMAGE_DIGEST` combination, the +`TaskRun` can also emit the `IMAGE_SBOM_URL` and the `IMAGE_SBOM_FORMAT` results. The first contains +a reference to an *unsigned* SBOM image. The second holds the desired predicate for the SBOM +attestation. All four results must be included, otherwise SBOM signing is skipped. Alternatively, if +using the `IMAGES` type hinting, use the `SBOMS` and the `SBOMS_FORMAT`. + +When processing a `TaskRun`, Chains will parse through the results, then sign and attest each image. +Optionally, it may also create signed SBOM attestations if the expected results are present. + When processing a `PipelineRun`, Chains will only attest each image. Thus, if both `TaskRun` and `PipelineRun` produce type hint results, each image will have one signature and two attestations. +SBOM-releated results are ignored for `PipelineRuns`. For in-toto attestations, see [intoto.md](intoto.md) for description of in-toto specific type hinting. @@ -76,6 +85,14 @@ Supported keys include: | `artifacts.oci.storage` | The storage backend to store `OCI` signatures in. Multiple backends can be specified with comma-separated list ("oci,tekton"). To disable the `OCI` artifact input an empty string ("").| `tekton`, `oci`, `gcs`, `docdb`, `grafeas` | `oci` | | `artifacts.oci.signer` | The signature backend to sign `OCI` payloads with. | `x509`, `kms` | `x509` | +### SBOM Configuration + +| Key | Description | Supported Values | Default | +| :--- | :--- | :--- | :--- | +| `artifacts.sbom.format` | The statement format to store `SBOM` payloads in. | `in-toto` | `in-toto` | +| `artifacts.sbom.storage` | The storage backend to store `SBOM` payloads in. Multiple backends can be specified with comma-separated list ("oci,tekton"). To disable the `OCI` artifact input an empty string ("").| `tekton`, `oci`, `gcs`, `docdb`, `grafeas` | `oci` | +| `artifacts.sbom.signer` | The signature backend to sign `SBOM` payloads with. | `x509`, `kms` | `x509` | + ### KMS Configuration | Key | Description | Supported Values | Default | diff --git a/pkg/artifacts/signable.go b/pkg/artifacts/signable.go index 5156a76f72..817d7622e6 100644 --- a/pkg/artifacts/signable.go +++ b/pkg/artifacts/signable.go @@ -149,8 +149,10 @@ type image struct { // URI is the resource uri for the target needed iff the target is a material. // Digest is the target's SHA digest. type StructuredSignable struct { - URI string - Digest string + URI string + Digest string + SBOMURI string + SBOMFormat string } func (oa *OCIArtifact) ExtractObjects(ctx context.Context, obj objects.TektonObject) []interface{} { @@ -201,7 +203,7 @@ func (oa *OCIArtifact) ExtractObjects(ctx context.Context, obj objects.TektonObj func ExtractOCIImagesFromResults(ctx context.Context, obj objects.TektonObject) []interface{} { logger := logging.FromContext(ctx) objs := []interface{}{} - ss := extractTargetFromResults(ctx, obj, "IMAGE_URL", "IMAGE_DIGEST") + ss := extractTargetFromResults(ctx, obj, "IMAGE_URL", "IMAGE_DIGEST", "", "") for _, s := range ss { if s == nil || s.Digest == "" || s.URI == "" { continue @@ -242,7 +244,7 @@ func ExtractOCIImagesFromResults(ctx context.Context, obj objects.TektonObject) func ExtractSignableTargetFromResults(ctx context.Context, obj objects.TektonObject) []*StructuredSignable { logger := logging.FromContext(ctx) objs := []*StructuredSignable{} - ss := extractTargetFromResults(ctx, obj, "ARTIFACT_URI", "ARTIFACT_DIGEST") + ss := extractTargetFromResults(ctx, obj, "ARTIFACT_URI", "ARTIFACT_DIGEST", "", "") // Only add it if we got both the signable URI and digest. for _, s := range ss { if s == nil || s.Digest == "" || s.URI == "" { @@ -264,7 +266,7 @@ func (s *StructuredSignable) FullRef() string { return fmt.Sprintf("%s@%s", s.URI, s.Digest) } -func extractTargetFromResults(ctx context.Context, obj objects.TektonObject, identifierSuffix string, digestSuffix string) map[string]*StructuredSignable { +func extractTargetFromResults(ctx context.Context, obj objects.TektonObject, identifierSuffix, digestSuffix, sbomSuffix, sbomFormatSuffix string) map[string]*StructuredSignable { logger := logging.FromContext(ctx) ss := map[string]*StructuredSignable{} @@ -295,6 +297,31 @@ func extractTargetFromResults(ctx context.Context, obj objects.TektonObject, ide ss[marker] = &StructuredSignable{Digest: strings.TrimSpace(res.Value.StringVal)} } } + if sbomSuffix != "" && strings.HasSuffix(res.Name, sbomSuffix) { + if res.Value.StringVal == "" { + logger.Debugf("error getting string value for %s", res.Name) + continue + } + marker := strings.TrimSuffix(res.Name, sbomSuffix) + sbomURI := strings.TrimSpace(res.Value.StringVal) + if v, ok := ss[marker]; ok { + v.SBOMURI = sbomURI + } else { + ss[marker] = &StructuredSignable{SBOMURI: sbomURI} + } + } + if sbomFormatSuffix != "" && strings.HasSuffix(res.Name, sbomFormatSuffix) { + if res.Value.StringVal == "" { + logger.Debugf("error getting string value for %s", res.Name) + } + marker := strings.TrimSuffix(res.Name, sbomFormatSuffix) + sbomFormat := strings.TrimSpace(res.Value.StringVal) + if v, ok := ss[marker]; ok { + v.SBOMFormat = sbomFormat + } else { + ss[marker] = &StructuredSignable{SBOMFormat: sbomFormat} + } + } } return ss @@ -435,3 +462,119 @@ func (oa *OCIArtifact) FullKey(obj interface{}) string { func (oa *OCIArtifact) Enabled(cfg config.Config) bool { return cfg.Artifacts.OCI.Enabled() } + +type SBOMArtifact struct{} + +var _ Signable = &SBOMArtifact{} + +func (sa *SBOMArtifact) ExtractObjects(ctx context.Context, tektonObject objects.TektonObject) []interface{} { + var objs []interface{} + for _, obj := range extractSBOMFromResults(ctx, tektonObject) { + objs = append(objs, objects.NewSBOMObject(obj.SBOMURI, obj.SBOMFormat, obj.URI, obj.Digest, tektonObject)) + } + return objs +} + +func (sa *SBOMArtifact) StorageBackend(cfg config.Config) sets.Set[string] { + return cfg.Artifacts.SBOM.StorageBackend +} +func (sa *SBOMArtifact) Signer(cfg config.Config) string { + return cfg.Artifacts.SBOM.Signer +} +func (sa *SBOMArtifact) PayloadFormat(cfg config.Config) config.PayloadType { + return config.PayloadType(cfg.Artifacts.SBOM.Format) +} + +func (sa *SBOMArtifact) FullKey(obj interface{}) string { + v := obj.(*objects.SBOMObject) + return v.GetSBOMURL() +} + +func (sa *SBOMArtifact) ShortKey(obj interface{}) string { + v := obj.(*objects.SBOMObject) + return v.GetSBOMURL() +} +func (sa *SBOMArtifact) Type() string { + return "sbom" +} +func (sa *SBOMArtifact) Enabled(cfg config.Config) bool { + return cfg.Artifacts.SBOM.Enabled() +} + +func extractSBOMFromResults(ctx context.Context, tektonObject objects.TektonObject) []*StructuredSignable { + logger := logging.FromContext(ctx) + var objs []*StructuredSignable + + ss := extractTargetFromResults(ctx, tektonObject, "IMAGE_URL", "IMAGE_DIGEST", "IMAGE_SBOM_URL", "IMAGE_SBOM_FORMAT") + for _, s := range ss { + if s == nil || s.Digest == "" || s.URI == "" || s.SBOMURI == "" || s.SBOMFormat == "" { + continue + } + if _, err := name.NewDigest(s.SBOMURI); err != nil { + logger.Errorf("error getting digest for SBOM image %s: %v", s.SBOMURI, err) + continue + } + objs = append(objs, s) + } + + var images []name.Digest + var sboms []string + var sbomsFormat string + // look for a comma separated list of images and their SBOMs + for _, key := range tektonObject.GetResults() { + switch key.Name { + case "IMAGES": + for _, img := range strings.FieldsFunc(key.Value.StringVal, split) { + trimmed := strings.TrimSpace(img) + if trimmed == "" { + continue + } + + dgst, err := name.NewDigest(trimmed) + if err != nil { + logger.Errorf("error getting digest for img %s: %v", trimmed, err) + continue + } + images = append(images, dgst) + } + case "SBOMS": + for _, img := range strings.FieldsFunc(key.Value.StringVal, split) { + trimmed := strings.TrimSpace(img) + if trimmed == "" { + continue + } + if _, err := name.NewDigest(trimmed); err != nil { + logger.Errorf("error getting digest for SBOM image %s: %v", trimmed, err) + continue + } + sboms = append(sboms, trimmed) + } + case "SBOMS_FORMAT": + f := strings.TrimSpace(key.Value.StringVal) + if f != "" { + sbomsFormat = f + } + } + } + + if len(images) != len(sboms) { + logger.Warnf("IMAGES and SBOMS do not contain the same amount of entries") + return objs + } + + if len(sboms) > 0 && sbomsFormat == "" { + logger.Warnf("SBOMS_FORMAT not specified") + return objs + } + + for i, sbom := range sboms { + img := images[i] + objs = append(objs, &StructuredSignable{ + URI: img.Name(), + Digest: img.Identifier(), + SBOMURI: sbom, + SBOMFormat: sbomsFormat, + }) + } + return objs +} diff --git a/pkg/artifacts/signable_test.go b/pkg/artifacts/signable_test.go index 4f78d623e4..3e2c5b43f3 100644 --- a/pkg/artifacts/signable_test.go +++ b/pkg/artifacts/signable_test.go @@ -638,6 +638,438 @@ func TestValidateResults(t *testing.T) { } } +func TestSBOMArtifact_ExtractObjects(t *testing.T) { + tests := []struct { + name string + obj objects.TektonObject + want []interface{} + }{ + { + name: "one SBOM image", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "IMAGE_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:latest"), + }, + { + Name: "IMAGE_DIGEST", + Value: *v1beta1.NewArrayOrString(digest1), + }, + { + Name: "IMAGE_SBOM_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:sbom@" + digest2), + }, + { + Name: "IMAGE_SBOM_FORMAT", + Value: *v1beta1.NewArrayOrString("https://cyclonedx.org/schema"), + }, + }, + }, + }, + }), + want: []interface{}{ + objects.NewSBOMObject( + "gcr.io/foo/bat:sbom@"+digest2, + "https://cyclonedx.org/schema", + "gcr.io/foo/bat:latest", + digest1, + nil, + ), + }, + }, + { + name: "multiple SBOM images", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "BAT_IMAGE_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:latest"), + }, + { + Name: "BAT_IMAGE_DIGEST", + Value: *v1beta1.NewArrayOrString(digest1), + }, + { + Name: "BAT_IMAGE_SBOM_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:sbom@" + digest2), + }, + { + Name: "BAT_IMAGE_SBOM_FORMAT", + Value: *v1beta1.NewArrayOrString("https://cyclonedx.org/schema"), + }, + { + Name: "BAZ_IMAGE_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/baz:latest"), + }, + { + Name: "BAZ_IMAGE_DIGEST", + Value: *v1beta1.NewArrayOrString(digest3), + }, + { + Name: "BAZ_IMAGE_SBOM_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/baz:sbom@" + digest4), + }, + { + Name: "BAZ_IMAGE_SBOM_FORMAT", + Value: *v1beta1.NewArrayOrString("https://spdx.dev/Document"), + }, + }, + }, + }, + }), + want: []interface{}{ + objects.NewSBOMObject( + "gcr.io/foo/bat:sbom@"+digest2, + "https://cyclonedx.org/schema", + "gcr.io/foo/bat:latest", + digest1, + nil, + ), + objects.NewSBOMObject( + "gcr.io/foo/baz:sbom@"+digest4, + "https://spdx.dev/Document", + "gcr.io/foo/baz:latest", + digest3, + nil, + ), + }, + }, + { + name: "multi-image result", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "IMAGES", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat@" + digest1 + "\ngcr.io/foo/baz@" + digest2 + "\n\n"), + }, + { + Name: "SBOMS", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:sbom@" + digest3 + "\ngcr.io/foo/baz:sbom@" + digest4 + "\n\n"), + }, + { + Name: "SBOMS_FORMAT", + Value: *v1beta1.NewArrayOrString("https://cyclonedx.org/schema"), + }, + }, + }, + }, + }), + want: []interface{}{ + objects.NewSBOMObject( + "gcr.io/foo/bat:sbom@"+digest3, + "https://cyclonedx.org/schema", + "gcr.io/foo/bat@"+digest1, + digest1, + nil, + ), + objects.NewSBOMObject( + "gcr.io/foo/baz:sbom@"+digest4, + "https://cyclonedx.org/schema", + "gcr.io/foo/baz@"+digest2, + digest2, + nil, + ), + }, + }, + { + name: "missing SBOMS_FORMAT", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "IMAGES", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat@" + digest1), + }, + { + Name: "SBOMS", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:sbom@" + digest2), + }, + }, + }, + }, + }), + want: nil, + }, + { + name: "missing IMAGES", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "SBOMS", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:sbom@" + digest3 + "\ngcr.io/foo/baz:sbom@" + digest4 + "\n\n"), + }, + { + Name: "SBOMS_FORMAT", + Value: *v1beta1.NewArrayOrString("https://cyclonedx.org/schema"), + }, + }, + }, + }, + }), + want: nil, + }, + { + name: "missing SBOMS", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "IMAGES", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat@" + digest1 + "\ngcr.io/foo/baz@" + digest2 + "\n\n"), + }, + { + Name: "SBOMS_FORMAT", + Value: *v1beta1.NewArrayOrString("https://cyclonedx.org/schema"), + }, + }, + }, + }, + }), + want: nil, + }, + { + name: "missing IMAGE_SBOM_FORMAT", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{{ + Name: "IMAGE_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:latest"), + }, + { + Name: "IMAGE_DIGEST", + Value: *v1beta1.NewArrayOrString(digest1), + }, + { + Name: "IMAGE_SBOM_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:sbom@" + digest2), + }, + }, + }, + }, + }), + want: nil, + }, + { + name: "missing IMAGE_URL", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "IMAGE_DIGEST", + Value: *v1beta1.NewArrayOrString(digest1), + }, + { + Name: "IMAGE_SBOM_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:sbom@" + digest2), + }, + { + Name: "IMAGE_SBOM_FORMAT", + Value: *v1beta1.NewArrayOrString("https://cyclonedx.org/schema"), + }, + }, + }, + }, + }), + want: nil, + }, + { + name: "missing IMAGE_DIGEST", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "IMAGE_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:latest"), + }, + { + Name: "IMAGE_SBOM_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:sbom@" + digest2), + }, + { + Name: "IMAGE_SBOM_FORMAT", + Value: *v1beta1.NewArrayOrString("https://cyclonedx.org/schema"), + }, + }, + }, + }, + }), + want: nil, + }, + { + name: "missing IMAGE_SBOM_URL", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "IMAGE_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:latest"), + }, + { + Name: "IMAGE_DIGEST", + Value: *v1beta1.NewArrayOrString(digest1), + }, + { + Name: "IMAGE_SBOM_FORMAT", + Value: *v1beta1.NewArrayOrString("https://cyclonedx.org/schema"), + }, + }, + }, + }, + }), + want: nil, + }, + { + name: "count mismatch", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "IMAGES", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat@" + digest1 + "\ngcr.io/foo/baz@" + digest2 + "\n\n"), + }, + { + Name: "SBOMS", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:sbom@" + digest3), + }, + { + Name: "SBOMS_FORMAT", + Value: *v1beta1.NewArrayOrString("https://cyclonedx.org/schema"), + }, + }, + }, + }, + }), + want: nil, + }, + { + name: "non-pinned SBOMs ignored", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "IMAGES", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat@" + digest1 + "\ngcr.io/foo/baz@" + digest2 + "\n\n"), + }, + { + Name: "SBOMS", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:sbom"), + }, + { + Name: "SBOMS_FORMAT", + Value: *v1beta1.NewArrayOrString("https://cyclonedx.org/schema"), + }, + }, + }, + }, + }), + want: nil, + }, + { + name: "non-pinned IMAGE_SBOM_URL ignored", + obj: objects.NewTaskRunObject(&v1beta1.TaskRun{ + TypeMeta: metav1.TypeMeta{ + Kind: "TaskRun", + }, + Status: v1beta1.TaskRunStatus{ + TaskRunStatusFields: v1beta1.TaskRunStatusFields{ + TaskRunResults: []v1beta1.TaskRunResult{ + { + Name: "IMAGE_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:latest"), + }, + { + Name: "IMAGE_DIGEST", + Value: *v1beta1.NewArrayOrString(digest1), + }, + { + Name: "IMAGE_SBOM_URL", + Value: *v1beta1.NewArrayOrString("gcr.io/foo/bat:sbom"), + }, + { + Name: "IMAGE_SBOM_FORMAT", + Value: *v1beta1.NewArrayOrString("https://cyclonedx.org/schema"), + }, + }, + }, + }, + }), + want: nil, + }, + } + + // Convert an SBOMObject into a string so it can be easily compared and a diff'ed. + transformer := cmp.Transformer("stringify", func(sbomObject *objects.SBOMObject) string { + return fmt.Sprintf( + "ImageDigest=%q\nImageURL=%q\nSBOMFormat=%q\nSBOMURL=%q", + sbomObject.GetImageDigest(), sbomObject.GetImageURL(), sbomObject.GetSBOMFormat(), + sbomObject.GetSBOMURL()) + }) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := logtesting.TestContextWithLogger(t) + sbomArtifact := &SBOMArtifact{} + got := sbomArtifact.ExtractObjects(ctx, tt.obj) + if !cmp.Equal(got, tt.want, transformer) { + t.Errorf("SBOMArtifact.ExtractObjects() = %s", cmp.Diff(got, tt.want, transformer)) + } + }) + } +} + func createDigest(t *testing.T, dgst string) name.Digest { result, err := name.NewDigest(dgst) if err != nil { diff --git a/pkg/chains/formats/format.go b/pkg/chains/formats/format.go index 690baa7b2e..2585b1d869 100644 --- a/pkg/chains/formats/format.go +++ b/pkg/chains/formats/format.go @@ -18,11 +18,12 @@ import ( "fmt" "github.com/tektoncd/chains/pkg/config" + "k8s.io/client-go/kubernetes" ) // Payloader is an interface to generate a chains Payload from a TaskRun type Payloader interface { - CreatePayload(ctx context.Context, obj interface{}) (interface{}, error) + CreatePayload(ctx context.Context, kc kubernetes.Interface, obj interface{}) (interface{}, error) Type() config.PayloadType Wrap() bool } diff --git a/pkg/chains/formats/sbom/sbom.go b/pkg/chains/formats/sbom/sbom.go new file mode 100644 index 0000000000..f9b0c583b0 --- /dev/null +++ b/pkg/chains/formats/sbom/sbom.go @@ -0,0 +1,106 @@ +/* +Copyright 2023 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sbom + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + intoto "github.com/in-toto/in-toto-golang/in_toto" + slsa "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + "github.com/tektoncd/chains/pkg/chains/objects" + "k8s.io/client-go/kubernetes" +) + +const maxSBOMBytes = 5 * 1024 * 1024 // 5 Megabytes + +func GenerateAttestation(ctx context.Context, kc kubernetes.Interface, builderID string, sbom *objects.SBOMObject) (interface{}, error) { + subject := []intoto.Subject{ + {Name: sbom.GetImageURL(), Digest: toDigestSet(sbom.GetImageDigest())}, + } + + data, err := getData(ctx, kc, sbom) + if err != nil { + return nil, err + } + + att := intoto.Statement{ + StatementHeader: intoto.StatementHeader{ + Type: intoto.StatementInTotoV01, + PredicateType: sbom.GetSBOMFormat(), + Subject: subject, + }, + Predicate: data, + } + return att, nil +} + +func getData(ctx context.Context, kc kubernetes.Interface, sbom *objects.SBOMObject) (json.RawMessage, error) { + opt, err := sbom.OCIRemoteOption(ctx, kc) + if err != nil { + return nil, err + } + + uri := sbom.GetSBOMURL() + ref, err := name.ParseReference(uri) + if err != nil { + return nil, err + } + + image, err := remote.Image(ref, opt) + if err != nil { + return nil, err + } + + layers, err := image.Layers() + if err != nil { + return nil, err + } + + if len(layers) != 1 { + return nil, fmt.Errorf("expected exactly 1 layer in sbom image %s, found %d", uri, len(layers)) + } + + layer, err := layers[0].Uncompressed() + if err != nil { + return nil, err + } + defer layer.Close() + + var blob bytes.Buffer + if _, err := io.Copy(&blob, io.LimitReader(layer, maxSBOMBytes)); err != nil { + return nil, err + } + + var data json.RawMessage + if err := json.Unmarshal(blob.Bytes(), &data); err != nil { + return nil, err + } + return data, nil +} + +func toDigestSet(digest string) slsa.DigestSet { + algo, value, found := strings.Cut(digest, ":") + if !found { + value = algo + algo = "sha256" + } + return slsa.DigestSet{algo: value} +} diff --git a/pkg/chains/formats/sbom/sbom_test.go b/pkg/chains/formats/sbom/sbom_test.go new file mode 100644 index 0000000000..5abbd750f7 --- /dev/null +++ b/pkg/chains/formats/sbom/sbom_test.go @@ -0,0 +1,167 @@ +/* +Copyright 2023 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sbom + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/in-toto/in-toto-golang/in_toto" + slsa "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + "github.com/sigstore/cosign/v2/pkg/oci/static" + "github.com/tektoncd/chains/pkg/chains/objects" + "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + fakekubeclient "knative.dev/pkg/client/injection/kube/client/fake" + rtesting "knative.dev/pkg/reconciler/testing" +) + +func TestGenerateAttestation(t *testing.T) { + namespace := "test-namespace" + serviceaccount := "test-serviceaccount" + tektonObject := objects.NewTaskRunObject(&v1beta1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, + Spec: v1beta1.TaskRunSpec{ServiceAccountName: serviceaccount}, + }) + + cases := []struct { + name string + sbomURL string + sbomFormat string + imageURL string + imageDigest string + sbom string + want in_toto.Statement + }{ + { + name: "simple", + sbomURL: testSBOMURL, + sbomFormat: testSBOMFormat, + imageURL: testImageURL, + imageDigest: testImageDigest, + sbom: `{"foo": "bar"}`, + want: in_toto.Statement{ + StatementHeader: in_toto.StatementHeader{ + Type: in_toto.StatementInTotoV01, + PredicateType: testSBOMFormat, + Subject: []in_toto.Subject{ + { + Name: testImageURL, + Digest: slsa.DigestSet{"sha256": testImageDigestNoAlgo}, + }, + }, + }, + Predicate: json.RawMessage(`{"foo": "bar"}`), + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ctx, _ := rtesting.SetupFakeContext(t) + + // Setup kubernetes resources + kc := fakekubeclient.Get(ctx) + if _, err := kc.CoreV1().ServiceAccounts(namespace).Create(ctx, &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: serviceaccount, Namespace: namespace}, + }, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + + // Setup registry + registry := httptest.NewServer(registry.New()) + defer registry.Close() + registryURL, err := url.Parse(registry.URL) + if err != nil { + t.Fatal(err) + } + + // Populate image references with registry host + sbomURL := fmt.Sprintf(c.sbomURL, registryURL.Host) + imageURL := fmt.Sprintf(c.imageURL, registryURL.Host) + + // Create SBOM image. + pinnedSBOM, err := pushSBOMImage(sbomURL, c.sbom) + if err != nil { + t.Fatal(err) + } + sbomURL = pinnedSBOM.String() + + // Setup SBOM Object + sbomObject := objects.NewSBOMObject( + sbomURL, + c.sbomFormat, + imageURL, + c.imageDigest, tektonObject) + + got, err := GenerateAttestation(ctx, kc, testBuilderID, sbomObject) + if err != nil { + t.Fatal(err) + } + + transformer := cmp.Transformer("registry", func(subject in_toto.Subject) in_toto.Subject { + if strings.Contains(subject.Name, "%s") { + subject.Name = fmt.Sprintf(subject.Name, registryURL.Host) + } + return subject + }) + if !cmp.Equal(got, c.want, transformer) { + t.Errorf("GenerateAttestation() = %s", cmp.Diff(got, c.want, transformer)) + } + }) + } + +} + +const ( + testSBOMURL = "%s/foo/bat:sbom" + testSBOMFormat = "https://cyclonedx.org/schema" + testSBOMMediaType = "application/vnd.cyclonedx+json" + testImageURL = "%s/foo/bat:latest" + testImageDigestNoAlgo = "05f95b26ed10668b7183c1e2da98610e91372fa9f510046d4ce5812addad86b6" + testImageDigest = "sha256:" + testImageDigestNoAlgo + testBuilderID = "test-builder-id" +) + +func pushSBOMImage(ref string, data string) (name.Digest, error) { + imgRef, err := name.ParseReference(ref) + if err != nil { + return name.Digest{}, err + } + img, err := static.NewFile([]byte(data), static.WithLayerMediaType(testSBOMMediaType)) + if err != nil { + return name.Digest{}, err + } + if err := remote.Write(imgRef, img); err != nil { + return name.Digest{}, err + } + + // Figure out the digest of the image just pushed + descriptor, err := remote.Head(imgRef) + if err != nil { + return name.Digest{}, err + } + digest := descriptor.Digest.String() + + return imgRef.Context().Digest(digest), nil +} diff --git a/pkg/chains/formats/simple/simple.go b/pkg/chains/formats/simple/simple.go index 10c464f96a..43f46f53fd 100644 --- a/pkg/chains/formats/simple/simple.go +++ b/pkg/chains/formats/simple/simple.go @@ -20,6 +20,7 @@ import ( "github.com/sigstore/sigstore/pkg/signature/payload" "github.com/tektoncd/chains/pkg/chains/formats" "github.com/tektoncd/chains/pkg/config" + "k8s.io/client-go/kubernetes" "github.com/google/go-containerregistry/pkg/name" ) @@ -39,7 +40,7 @@ type SimpleSigning struct{} type SimpleContainerImage payload.SimpleContainerImage // CreatePayload implements the Payloader interface. -func (i *SimpleSigning) CreatePayload(ctx context.Context, obj interface{}) (interface{}, error) { +func (i *SimpleSigning) CreatePayload(ctx context.Context, _ kubernetes.Interface, obj interface{}) (interface{}, error) { switch v := obj.(type) { case name.Digest: format := NewSimpleStruct(v) diff --git a/pkg/chains/formats/simple/simple_test.go b/pkg/chains/formats/simple/simple_test.go index a3be8461a0..fc091210f7 100644 --- a/pkg/chains/formats/simple/simple_test.go +++ b/pkg/chains/formats/simple/simple_test.go @@ -62,7 +62,7 @@ func TestSimpleSigning_CreatePayload(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { i := &SimpleSigning{} - got, err := i.CreatePayload(context.Background(), tt.obj) + got, err := i.CreatePayload(context.Background(), nil, tt.obj) if (err != nil) != tt.wantErr { t.Errorf("SimpleSigning.CreatePayload() error = %v, wantErr %v", err, tt.wantErr) return @@ -82,7 +82,7 @@ func TestImageName(t *testing.T) { obj := makeDigest(t, img) i := &SimpleSigning{} - format, err := i.CreatePayload(context.Background(), obj) + format, err := i.CreatePayload(context.Background(), nil, obj) if err != nil { t.Fatal(err) } diff --git a/pkg/chains/formats/slsa/v1/intotoite6.go b/pkg/chains/formats/slsa/v1/intotoite6.go index e6c7a1fe64..785ea3cba8 100644 --- a/pkg/chains/formats/slsa/v1/intotoite6.go +++ b/pkg/chains/formats/slsa/v1/intotoite6.go @@ -21,10 +21,12 @@ import ( "fmt" "github.com/tektoncd/chains/pkg/chains/formats" + "github.com/tektoncd/chains/pkg/chains/formats/sbom" "github.com/tektoncd/chains/pkg/chains/formats/slsa/v1/pipelinerun" "github.com/tektoncd/chains/pkg/chains/formats/slsa/v1/taskrun" "github.com/tektoncd/chains/pkg/chains/objects" "github.com/tektoncd/chains/pkg/config" + "k8s.io/client-go/kubernetes" ) const ( @@ -51,12 +53,16 @@ func (i *InTotoIte6) Wrap() bool { return true } -func (i *InTotoIte6) CreatePayload(ctx context.Context, obj interface{}) (interface{}, error) { +func (i *InTotoIte6) CreatePayload(ctx context.Context, kc kubernetes.Interface, obj interface{}) (interface{}, error) { switch v := obj.(type) { case *objects.TaskRunObject: return taskrun.GenerateAttestation(ctx, i.builderID, v) case *objects.PipelineRunObject: return pipelinerun.GenerateAttestation(ctx, i.builderID, v) + case *objects.SBOMObject: + // TODO: It is odd that the slsa package has a dependency on the sbom package. But, + // this is required for now since intotoite6 is currently a part of the slsa package. + return sbom.GenerateAttestation(ctx, kc, i.builderID, v) default: return nil, fmt.Errorf("intoto does not support type: %s", v) } diff --git a/pkg/chains/formats/slsa/v1/intotoite6_test.go b/pkg/chains/formats/slsa/v1/intotoite6_test.go index f9281488b6..014fe5fc1e 100644 --- a/pkg/chains/formats/slsa/v1/intotoite6_test.go +++ b/pkg/chains/formats/slsa/v1/intotoite6_test.go @@ -133,7 +133,7 @@ func TestTaskRunCreatePayload1(t *testing.T) { } i, _ := NewFormatter(cfg) - got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) + got, err := i.CreatePayload(ctx, nil, objects.NewTaskRunObject(tr)) if err != nil { t.Errorf("unexpected error: %s", err.Error()) @@ -359,7 +359,7 @@ func TestPipelineRunCreatePayload(t *testing.T) { i, _ := NewFormatter(cfg) - got, err := i.CreatePayload(ctx, pro) + got, err := i.CreatePayload(ctx, nil, pro) if err != nil { t.Errorf("unexpected error: %s", err.Error()) } @@ -576,7 +576,7 @@ func TestPipelineRunCreatePayloadChildRefs(t *testing.T) { pro.AppendTaskRun(tr2) i, _ := NewFormatter(cfg) - got, err := i.CreatePayload(ctx, pro) + got, err := i.CreatePayload(ctx, nil, pro) if err != nil { t.Errorf("unexpected error: %s", err.Error()) } @@ -651,7 +651,7 @@ func TestTaskRunCreatePayload2(t *testing.T) { }, } i, _ := NewFormatter(cfg) - got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) + got, err := i.CreatePayload(ctx, nil, objects.NewTaskRunObject(tr)) if err != nil { t.Errorf("unexpected error: %s", err.Error()) @@ -722,7 +722,7 @@ func TestMultipleSubjects(t *testing.T) { } i, _ := NewFormatter(cfg) - got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) + got, err := i.CreatePayload(ctx, nil, objects.NewTaskRunObject(tr)) if err != nil { t.Errorf("unexpected error: %s", err.Error()) } @@ -759,7 +759,7 @@ func TestCreatePayloadError(t *testing.T) { f, _ := NewFormatter(cfg) t.Run("Invalid type", func(t *testing.T) { - p, err := f.CreatePayload(ctx, "not a task ref") + p, err := f.CreatePayload(ctx, nil, "not a task ref") if p != nil { t.Errorf("Unexpected payload") diff --git a/pkg/chains/formats/slsa/v2/slsav2.go b/pkg/chains/formats/slsa/v2/slsav2.go index 3242ca3b24..6428b25b3a 100644 --- a/pkg/chains/formats/slsa/v2/slsav2.go +++ b/pkg/chains/formats/slsa/v2/slsav2.go @@ -24,6 +24,7 @@ import ( "github.com/tektoncd/chains/pkg/chains/formats/slsa/v2/taskrun" "github.com/tektoncd/chains/pkg/chains/objects" "github.com/tektoncd/chains/pkg/config" + "k8s.io/client-go/kubernetes" ) const ( @@ -48,7 +49,7 @@ func (s *Slsa) Wrap() bool { return true } -func (s *Slsa) CreatePayload(ctx context.Context, obj interface{}) (interface{}, error) { +func (s *Slsa) CreatePayload(ctx context.Context, kc kubernetes.Interface, obj interface{}) (interface{}, error) { switch v := obj.(type) { case *objects.TaskRunObject: return taskrun.GenerateAttestation(ctx, s.builderID, s.Type(), v) diff --git a/pkg/chains/formats/slsa/v2/slsav2_test.go b/pkg/chains/formats/slsa/v2/slsav2_test.go index 8986ceef49..5c2eed9c2d 100644 --- a/pkg/chains/formats/slsa/v2/slsav2_test.go +++ b/pkg/chains/formats/slsa/v2/slsav2_test.go @@ -161,7 +161,7 @@ func TestTaskRunCreatePayload1(t *testing.T) { } i, _ := NewFormatter(cfg) - got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) + got, err := i.CreatePayload(ctx, nil, objects.NewTaskRunObject(tr)) if err != nil { t.Errorf("unexpected error: %s", err.Error()) @@ -262,7 +262,7 @@ func TestTaskRunCreatePayload2(t *testing.T) { }, } i, _ := NewFormatter(cfg) - got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) + got, err := i.CreatePayload(ctx, nil, objects.NewTaskRunObject(tr)) if err != nil { t.Errorf("unexpected error: %s", err.Error()) @@ -360,7 +360,7 @@ func TestMultipleSubjects(t *testing.T) { } i, _ := NewFormatter(cfg) - got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) + got, err := i.CreatePayload(ctx, nil, objects.NewTaskRunObject(tr)) if err != nil { t.Errorf("unexpected error: %s", err.Error()) } @@ -397,7 +397,7 @@ func TestCreatePayloadError(t *testing.T) { f, _ := NewFormatter(cfg) t.Run("Invalid type", func(t *testing.T) { - p, err := f.CreatePayload(ctx, "not a task ref") + p, err := f.CreatePayload(ctx, nil, "not a task ref") if p != nil { t.Errorf("Unexpected payload") diff --git a/pkg/chains/objects/objects.go b/pkg/chains/objects/objects.go index bb8a73b4f8..e07c4d49c3 100644 --- a/pkg/chains/objects/objects.go +++ b/pkg/chains/objects/objects.go @@ -18,12 +18,15 @@ import ( "errors" "fmt" + "github.com/google/go-containerregistry/pkg/authn/k8schain" + "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/tektoncd/pipeline/pkg/apis/pipeline/pod" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" "github.com/tektoncd/pipeline/pkg/client/clientset/versioned" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" "knative.dev/pkg/apis" ) @@ -223,3 +226,50 @@ func getPodPullSecrets(podTemplate *pod.Template) []string { } return imgPullSecrets } + +type SBOMObject struct { + sbomURL string + sbomFormat string + imageURL string + imageDigest string + tektonObject TektonObject +} + +func NewSBOMObject(sbomURL, sbomFormat, imageURL, imageDigest string, tektonObject TektonObject) *SBOMObject { + return &SBOMObject{ + sbomURL: sbomURL, + sbomFormat: sbomFormat, + imageURL: imageURL, + imageDigest: imageDigest, + tektonObject: tektonObject, + } +} + +func (so *SBOMObject) GetSBOMURL() string { + return so.sbomURL +} + +func (so *SBOMObject) GetSBOMFormat() string { + return so.sbomFormat +} + +func (so *SBOMObject) GetImageURL() string { + return so.imageURL +} + +func (so *SBOMObject) GetImageDigest() string { + return so.imageDigest +} + +func (so *SBOMObject) OCIRemoteOption(ctx context.Context, client kubernetes.Interface) (remote.Option, error) { + keychain, err := k8schain.New(ctx, client, + k8schain.Options{ + Namespace: so.tektonObject.GetNamespace(), + ServiceAccountName: so.tektonObject.GetServiceAccountName(), + ImagePullSecrets: so.tektonObject.GetPullSecrets(), + }) + if err != nil { + return nil, err + } + return remote.WithAuthFromKeychain(keychain), nil +} diff --git a/pkg/chains/objects/objects_test.go b/pkg/chains/objects/objects_test.go index e71f7cb92a..cd5a139649 100644 --- a/pkg/chains/objects/objects_test.go +++ b/pkg/chains/objects/objects_test.go @@ -20,7 +20,10 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline/pod" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + fakekubeclient "knative.dev/pkg/client/injection/kube/client/fake" + rtesting "knative.dev/pkg/reconciler/testing" ) func getPullSecretTemplate(pullSecret string) *pod.PodTemplate { @@ -196,3 +199,33 @@ func TestTaskRun_GetResults(t *testing.T) { }) } + +func TestSBOMObject(t *testing.T) { + namespace := "test-namespace" + serviceaccount := "test-serviceaccount" + + taskRun := getTaskRun() + taskRun.ObjectMeta.Namespace = namespace + taskRun.Spec.ServiceAccountName = serviceaccount + taskRunObject := NewTaskRunObject(taskRun) + + sbomObject := NewSBOMObject("sbomURL", "sbomFormat", "imageURL", "imageDigest", taskRunObject) + + assert.Equal(t, "sbomURL", sbomObject.GetSBOMURL()) + assert.Equal(t, "sbomFormat", sbomObject.GetSBOMFormat()) + assert.Equal(t, "imageURL", sbomObject.GetImageURL()) + assert.Equal(t, "imageDigest", sbomObject.GetImageDigest()) + + ctx, _ := rtesting.SetupFakeContext(t) + kc := fakekubeclient.Get(ctx) + if _, err := kc.CoreV1().ServiceAccounts(namespace).Create(ctx, &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: serviceaccount, Namespace: namespace}, + }, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + + got, err := sbomObject.OCIRemoteOption(ctx, kc) + assert.NoError(t, err) + // TODO: Not sure how to compare the returned remote.Option + assert.NotNil(t, got) +} diff --git a/pkg/chains/signing.go b/pkg/chains/signing.go index 2e3b1a6be6..90051ba269 100644 --- a/pkg/chains/signing.go +++ b/pkg/chains/signing.go @@ -31,6 +31,7 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" versioned "github.com/tektoncd/pipeline/pkg/client/clientset/versioned" "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/kubernetes" "knative.dev/pkg/logging" ) @@ -45,6 +46,7 @@ type ObjectSigner struct { Backends map[string]storage.Backend SecretPath string Pipelineclientset versioned.Interface + KubeClient kubernetes.Interface } func allSigners(ctx context.Context, sp string, cfg config.Config) map[string]signing.Signer { @@ -90,6 +92,7 @@ func getSignableTypes(ctx context.Context, obj objects.TektonObject) ([]artifact return []artifacts.Signable{ &artifacts.TaskRunArtifact{}, &artifacts.OCIArtifact{}, + &artifacts.SBOMArtifact{}, }, nil case *v1beta1.PipelineRun: return []artifacts.Signable{ @@ -133,8 +136,7 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject) // Go through each object one at a time. for _, obj := range objects { - - payload, err := payloader.CreatePayload(ctx, obj) + payload, err := payloader.CreatePayload(ctx, o.KubeClient, obj) if err != nil { logger.Error(err) continue diff --git a/pkg/config/config.go b/pkg/config/config.go index 9199cd8647..aff82d3313 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -40,6 +40,7 @@ type ArtifactConfigs struct { OCI Artifact PipelineRuns Artifact TaskRuns Artifact + SBOM Artifact } // Artifact contains the configuration for how to sign/store/format the signatures for a single artifact @@ -158,6 +159,10 @@ const ( ociStorageKey = "artifacts.oci.storage" ociSignerKey = "artifacts.oci.signer" + sbomFormatKey = "artifacts.sbom.format" + sbomStorageKey = "artifacts.sbom.storage" + sbomSignerKey = "artifacts.sbom.signer" + gcsBucketKey = "storage.gcs.bucket" ociRepositoryKey = "storage.oci.repository" ociRepositoryInsecureKey = "storage.oci.repository.insecure" @@ -223,6 +228,11 @@ func defaultConfig() *Config { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, }, Transparency: TransparencyConfig{ URL: "https://rekor.sigstore.dev", @@ -266,6 +276,11 @@ func NewConfigFromMap(data map[string]string) (*Config, error) { asStringSet(ociStorageKey, &cfg.Artifacts.OCI.StorageBackend, sets.New[string]("tekton", "oci", "gcs", "docdb", "grafeas", "kafka")), asString(ociSignerKey, &cfg.Artifacts.OCI.Signer, "x509", "kms"), + // SBOM + asString(sbomFormatKey, &cfg.Artifacts.SBOM.Format, "in-toto"), + asStringSet(sbomStorageKey, &cfg.Artifacts.SBOM.StorageBackend, sets.New[string]("tekton", "oci", "gcs", "docdb", "grafeas", "kafka")), + asString(sbomSignerKey, &cfg.Artifacts.SBOM.Signer, "x509", "kms"), + // PubSub - General asString(pubsubProvider, &cfg.Storage.PubSub.Provider, "inmemory", "kafka"), asString(pubsubTopic, &cfg.Storage.PubSub.Topic), diff --git a/pkg/config/store_test.go b/pkg/config/store_test.go index 92cf94521a..293e38f717 100644 --- a/pkg/config/store_test.go +++ b/pkg/config/store_test.go @@ -112,6 +112,11 @@ var defaultArtifacts = ArtifactConfigs{ StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, } var defaultStorage = StorageConfigs{ @@ -129,6 +134,7 @@ func TestParse(t *testing.T) { name string data map[string]string taskrunEnabled bool + sbomEnabled bool ociEnbaled bool want Config }{ @@ -137,6 +143,7 @@ func TestParse(t *testing.T) { data: map[string]string{}, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: defaultArtifacts, @@ -151,6 +158,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: BuilderConfig{ "builder-id-test", @@ -167,6 +175,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: defaultArtifacts, @@ -184,6 +193,7 @@ func TestParse(t *testing.T) { data: map[string]string{taskrunStorageKey: "tekton,oci"}, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -202,6 +212,11 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -213,6 +228,7 @@ func TestParse(t *testing.T) { data: map[string]string{taskrunStorageKey: ""}, taskrunEnabled: false, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -231,6 +247,11 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -242,6 +263,7 @@ func TestParse(t *testing.T) { data: map[string]string{ociStorageKey: "oci,tekton"}, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -260,6 +282,11 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci", "tekton"), Signer: "x509", }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -271,6 +298,7 @@ func TestParse(t *testing.T) { data: map[string]string{ociStorageKey: ""}, taskrunEnabled: true, ociEnbaled: false, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -289,6 +317,11 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string](""), Signer: "x509", }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -303,6 +336,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: false, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -321,6 +355,11 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string](""), Signer: "x509", }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -335,6 +374,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: false, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -353,6 +393,89 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci", "tekton"), Signer: "x509", }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + }, + Signers: defaultSigners, + Storage: defaultStorage, + Transparency: defaultTransparency, + }, + }, + { + name: "sbom", + data: map[string]string{ + sbomStorageKey: "oci,tekton", + sbomFormatKey: "in-toto", + sbomSignerKey: "kms", + }, + taskrunEnabled: true, + ociEnbaled: true, + sbomEnabled: true, + want: Config{ + Builder: defaultBuilder, + Artifacts: ArtifactConfigs{ + TaskRuns: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("tekton"), + Signer: "x509", + }, + PipelineRuns: Artifact{ + Format: "in-toto", + Signer: "x509", + StorageBackend: sets.New[string]("tekton"), + }, + OCI: Artifact{ + Format: "simplesigning", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci", "tekton"), + Signer: "kms", + }, + }, + Signers: defaultSigners, + Storage: defaultStorage, + Transparency: defaultTransparency, + }, + }, + { + name: "sbom disabled", + data: map[string]string{ + sbomStorageKey: "", + sbomFormatKey: "in-toto", + sbomSignerKey: "kms", + }, + taskrunEnabled: true, + ociEnbaled: true, + sbomEnabled: false, + want: Config{ + Builder: defaultBuilder, + Artifacts: ArtifactConfigs{ + TaskRuns: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("tekton"), + Signer: "x509", + }, + PipelineRuns: Artifact{ + Format: "in-toto", + Signer: "x509", + StorageBackend: sets.New[string]("tekton"), + }, + OCI: Artifact{ + Format: "simplesigning", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string](""), + Signer: "kms", + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -364,6 +487,7 @@ func TestParse(t *testing.T) { data: map[string]string{taskrunSignerKey: "x509"}, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -382,6 +506,11 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -393,6 +522,7 @@ func TestParse(t *testing.T) { data: map[string]string{transparencyEnabledKey: "manual"}, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: defaultArtifacts, @@ -413,6 +543,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -431,6 +562,11 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -445,6 +581,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -463,6 +600,11 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, }, Signers: SignerConfigs{ X509: X509Signer{ @@ -482,6 +624,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: defaultArtifacts, @@ -505,6 +648,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: defaultArtifacts, @@ -536,6 +680,9 @@ func TestParse(t *testing.T) { if got.Artifacts.TaskRuns.Enabled() != tt.taskrunEnabled { t.Errorf("Taskrun artifact enable mismatch") } + if got.Artifacts.SBOM.Enabled() != tt.sbomEnabled { + t.Errorf("SBOM artifact enable mismatch") + } if diff := cmp.Diff(*got, tt.want); diff != "" { t.Errorf("parse() = %v", diff) } diff --git a/pkg/reconciler/pipelinerun/controller.go b/pkg/reconciler/pipelinerun/controller.go index bc3d7ad47e..397a5528f1 100644 --- a/pkg/reconciler/pipelinerun/controller.go +++ b/pkg/reconciler/pipelinerun/controller.go @@ -44,6 +44,7 @@ func NewController(ctx context.Context, cmw configmap.Watcher) *controller.Impl psSigner := &chains.ObjectSigner{ SecretPath: SecretPath, Pipelineclientset: pipelineClient, + KubeClient: kubeClient, } c := &Reconciler{ diff --git a/pkg/reconciler/taskrun/controller.go b/pkg/reconciler/taskrun/controller.go index dbbb1cdab9..d7d2333121 100644 --- a/pkg/reconciler/taskrun/controller.go +++ b/pkg/reconciler/taskrun/controller.go @@ -40,6 +40,7 @@ func NewController(ctx context.Context, cmw configmap.Watcher) *controller.Impl tsSigner := &chains.ObjectSigner{ SecretPath: SecretPath, Pipelineclientset: pipelineClient, + KubeClient: kubeClient, } c := &Reconciler{