diff --git a/pkg/artifacts/signable_test.go b/pkg/artifacts/signable_test.go index 1b1b6d2ddf..aab2b2ea10 100644 --- a/pkg/artifacts/signable_test.go +++ b/pkg/artifacts/signable_test.go @@ -637,6 +637,440 @@ 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) { + logger := logtesting.TestLogger(t) + sbomArtifact := &SBOMArtifact{ + Logger: logger, + } + got := sbomArtifact.ExtractObjects(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/sbom/sbom.go b/pkg/chains/formats/sbom/sbom.go index 5fca1e8f3f..f9b0c583b0 100644 --- a/pkg/chains/formats/sbom/sbom.go +++ b/pkg/chains/formats/sbom/sbom.go @@ -26,13 +26,12 @@ import ( 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" - "go.uber.org/zap" "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, logger *zap.SugaredLogger) (interface{}, error) { +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())}, } diff --git a/pkg/chains/formats/sbom/sbom_test.go b/pkg/chains/formats/sbom/sbom_test.go new file mode 100644 index 0000000000..c3c4f0e550 --- /dev/null +++ b/pkg/chains/formats/sbom/sbom_test.go @@ -0,0 +1,166 @@ +/* +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/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. + if newSBOMURL, err := pushSBOMImage(sbomURL, c.sbom); err != nil { + t.Fatal(err) + sbomURL = newSBOMURL.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/slsa/v1/intotoite6.go b/pkg/chains/formats/slsa/v1/intotoite6.go index 60d86b1b8d..1e97b45fdc 100644 --- a/pkg/chains/formats/slsa/v1/intotoite6.go +++ b/pkg/chains/formats/slsa/v1/intotoite6.go @@ -64,7 +64,7 @@ func (i *InTotoIte6) CreatePayload(ctx context.Context, kc kubernetes.Interface, 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, logger) + 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/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) +}