From 59f6a5c8af56ee590f3f59656448b98cf36eaaf4 Mon Sep 17 00:00:00 2001 From: Luiz Carvalho Date: Fri, 11 Aug 2023 11:29:09 -0400 Subject: [PATCH] Add support for SBOM attestations This commit teaches Chains to create SBOM attestations based on type hinting results from TaskRuns. The general idea is that a Task generates the SBOM, then uploads it as a blob to an OCI registry. Chains then downloads the blob and uses it as the payload when creating the SBOM in-toto attestation. Multiple SBOMs per image are allowed. They may be of different formats. Signed-off-by: Luiz Carvalho --- docs/config.md | 21 +- docs/tutorials/signed-sbom.md | 175 +++++++ examples/sbom/sbom-pipeline.yaml | 98 ++++ examples/sbom/sbom-task.yaml | 70 +++ pkg/artifacts/sbom.go | 148 ++++++ pkg/artifacts/sbom_test.go | 482 ++++++++++++++++++ pkg/artifacts/signable.go | 92 +--- pkg/artifacts/signable_test.go | 11 +- pkg/artifacts/structured.go | 98 ++++ pkg/chains/formats/format.go | 6 +- pkg/chains/formats/sbom/sbom.go | 95 ++++ pkg/chains/formats/sbom/sbom_test.go | 164 ++++++ pkg/chains/formats/simple/simple.go | 4 +- pkg/chains/formats/slsa/v1/intotoite6.go | 18 +- pkg/chains/formats/slsa/v1/intotoite6_test.go | 19 +- pkg/chains/formats/slsa/v2alpha1/slsav2.go | 2 +- .../formats/slsa/v2alpha1/slsav2_test.go | 15 +- pkg/chains/formats/slsa/v2alpha2/slsav2.go | 2 +- .../formats/slsa/v2alpha2/slsav2_test.go | 17 +- pkg/chains/objects/objects.go | 59 +++ pkg/chains/objects/objects_test.go | 33 ++ pkg/chains/signing.go | 17 +- pkg/config/config.go | 45 ++ pkg/config/store_test.go | 193 +++++++ pkg/internal/mocksigner/mocksigner.go | 4 + pkg/reconciler/pipelinerun/controller.go | 7 +- pkg/reconciler/taskrun/controller.go | 7 +- 27 files changed, 1789 insertions(+), 113 deletions(-) create mode 100644 docs/tutorials/signed-sbom.md create mode 100644 examples/sbom/sbom-pipeline.yaml create mode 100644 examples/sbom/sbom-task.yaml create mode 100644 pkg/artifacts/sbom.go create mode 100644 pkg/artifacts/sbom_test.go create mode 100644 pkg/artifacts/structured.go 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 70afaf76f1..daff3e3464 100644 --- a/docs/config.md +++ b/docs/config.md @@ -33,9 +33,19 @@ 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. This reference must include the digest of the 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. @@ -78,6 +88,15 @@ 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` | +| `artifacts.sbom.maxbytes` | The maximum size, in bytes, allowed for SBOMs. | `integer` | `10485760`, 10 MB | + ### KMS Configuration | Key | Description | Supported Values | Default | diff --git a/docs/tutorials/signed-sbom.md b/docs/tutorials/signed-sbom.md new file mode 100644 index 0000000000..a0f5fe66b2 --- /dev/null +++ b/docs/tutorials/signed-sbom.md @@ -0,0 +1,175 @@ + + +# Chains Signed SBOM Tutorial + +This tutorial details the steps required to use Tekton Chains to sign an SBOM for an image. It adds +SBOM generation to a minimal clone-build-push Pipeline. + +At the end of this tutorial, the built image will contain a signed SBOM attestation. NOTE: This is +different than an SBOM attachment. SBOM attestations have a securer link to the described image as +well as the flexibility to provide multiple SBOMs for the same image. + +## Prerequisites + +A Kubernetes cluster with the following installed: + +* Tekton Chains +* Tekton Pipelines + +## Generate a Key Pair + +First, we'll generate an encrypted x509 keypair and save it as a Kubernetes secret. Install +[cosign](https://github.com/sigstore/cosign) and run the following: + +```shell +cosign generate-key-pair k8s://tekton-chains/signing-secrets +``` + +`cosign` will prompt you for a password, which will be stored in a Kubernetes secret named +`signing-secrets` in the `tekton-chains` namespace. + +The public key will be written to a local file called `cosign.pub`. + +## Set up Authentication + +There are two forms of authentication that need to be set up: + +1. The Chains controller will push signatures and attestations to an OCI registry using the + credentials linked to your `TaskRun`'s service account. See our [authentication + doc](../authentication.md) +2. The build and sbom Tasks will build and push content to the OCI registry. + +Both of those can be setup by creating a `docker-registry` secret and linking it to the +ServiceAccount used by the TaskRuns. This tutorial assumes the ServiceAccount is `default`. + +```shell +# Create a secret based on your local docker config. +kubectl create secret docker-registry tutorial-secret \ + --from-file=.dockerconfigjson=$HOME/.docker/config.json + +# Link secret to service account +kubectl patch serviceaccount default -p '{"imagePullSecrets": [{"name": "tutorial-secret"}]}' +kubectl patch serviceaccount default -p '{"secrets": [{"name": "tutorial-secret"}]}' +``` + +## Configure Tekton Chains + +You'll need to make these changes to the Tekton Chains Config: + +* `artifacts.sbom.format=in-toto` +* `artifacts.sbom.storage=oci` +* `transparency.enabled=true` + +You can set these fields by running: + +```shell +kubectl patch configmap chains-config -n tekton-chains -p='{"data":{"artifacts.sbom.format": "in-toto"}}' +kubectl patch configmap chains-config -n tekton-chains -p='{"data":{"artifacts.sbom.storage": "oci"}}' +kubectl patch configmap chains-config -n tekton-chains -p='{"data":{"transparency.enabled": "true"}}' +``` + +This tells Chains to generate an in-toto attestation for the SBOM and store it in the image's OCI +registry. The SBOM signature will also be stored in [rekor](https://github.com/sigstore/rekor) since +transparency is enabled. + +## Create SBOM Task + +Similar to image signatures, Tekton Chains requires that the task producing the SBOM emits results +[named in a certain way](https://tekton.dev/docs/chains/config/#chains-type-hinting). This section +creates a sample Tekton Task that meets these requirements. + +The [sample sbom Task](../../examples/sbom/sbom-task.yaml) uses +[syft](https://github.com/anchore/syft) to generate an SBOM in the CycloneDX format for a given +image. It then stores the generated SBOM as blob in the OCI registry. When the Task completes, +Tekton Chains downloads the SBOM blob and uses it as the payload of a new signed attestation that is +then attached to the image. To create it: + +```shell +kubectl apply -f examples/sbom/sbom-task.yaml +``` + +It is possible to run this Task to see SBOM signing in action. But let's use it in a Pipeline for a +more realistic example. + +## Build Pipeline + +The [sample build pipeline](../../examples/sbom/sbom-pipeline.yaml) uses git to clone an application +repository, buildah to build an application container image, and syft to create an SBOM for the +image. To create it: + +```shell +kubectl apply -f examples/sbom/sbom-pipeline.yaml +``` + +## Running + +Let's use the tkn CLI to run the build pipeline. To do so, you'll need an OCI registry to push the +image to and a git repository that contains a valid Dockerfile in its root. For example: + +```shell +# Set these accordingly +GIT_REPO=https://github.com/user/example +GIT_REVISION=main +OCI_REPO=quay.io/user/example + +tkn -n minimal-container pipeline start simple-build \ + --param git-repo=${GIT_REPO} --param git-revision=${GIT_REVISION} \ + --param output-image=${OCI_REPO}:latest --param sbom-repo=${OCI_REPO} \ + --workspace name=shared,pvc,claimName="tekton-build" \ + --showlog +``` + +NOTE: The above assumes you have an existing PVC named `tekton-build`. + +Towards the end of the log, there should a line like this: + +```text +[sbom : store-sbom-blob] Digest: sha256:bb19013e908abf6d0d024d82c8990c30e84bea29796a361d828f78499b7ddf12 +``` + +Make a note of the digest. This is the expected digest of the contents of the SBOM. + +The digest can also be retrieved from the TaskRun's result: + +```shell +kubectl get taskrun $TASK_RUN_NAME -o yaml | \ + yq '.status.results[] | select(.name == "IMAGE_SBOM_URL") | .value' +``` + +## Verification + +At this point, the image is built and should have an SBOM attestation attached to it. Use cosign to +inspect it: + +```shell +cosign download attestation ${OCI_REPO}:latest | \ + jq '.payload | @base64d | fromjson | select(.predicateType == "https://cyclonedx.org/schema") | .predicate' +``` + +Pipe the command above through `sha256sum`. The digest should match the digest of the SBOM retrieved +in the previous section. + +You can also use cosign to verify that the SBOM is in fact signed: + +```shell +cosign verify-attestation --key cosign.pub \ + --type 'https://cyclonedx.org/schema' ${OCI_REPO}:latest | \ + jq '.payload | @base64d | fromjson | .predicate' | sha256sum +``` + +NOTE: The above assumes you still have the `cosign.pub` file from the previous step. If you don't, +you can load the public key directly from Kubernetes by using `k8s://tekton-chains/signing-secrets` +as the value for the `--key` flag. + +NOTE: If you do not set `transparency.enabled` to `true` in `chains-config`, you must use the flag +`--insecure-ignore-tlog` in the command above. + +## Large SBOMs + +Because Tekton Chains has to read the SBOM in order to sign it, it has a default maxium size of +10MB. This can be adjusted via the `artifacts.sbom.maxbytes` property in `chains-config`. diff --git a/examples/sbom/sbom-pipeline.yaml b/examples/sbom/sbom-pipeline.yaml new file mode 100644 index 0000000000..9529d11c07 --- /dev/null +++ b/examples/sbom/sbom-pipeline.yaml @@ -0,0 +1,98 @@ +# 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. + +apiVersion: tekton.dev/v1beta1 +kind: Pipeline +metadata: + name: simple-build +spec: + params: + - description: Repository URL to clone from. + name: git-repo + type: string + - default: main + description: Revision to checkout. (branch, tag, sha, ref, etc...) + name: git-revision + type: string + - description: Reference of the image the pipeline will produce. + name: output-image + type: string + - description: OCI repo to temporarily store the SBOM blob. + name: sbom-repo + type: string + results: + - description: Reference of the image the pipeline will produce. + name: IMAGE_URL + value: $(tasks.build.results.IMAGE_URL) + - description: Digest of the image the pipeline will produce. + name: IMAGE_DIGEST + value: $(tasks.build.results.IMAGE_DIGEST) + - description: Repository URL used for buiding the image. + name: CHAINS-GIT_URL + value: $(tasks.clone.results.url) + - description: Repository commit used for building the image. + name: CHAINS-GIT_COMMIT + value: $(tasks.clone.results.commit) + tasks: + - name: clone + taskRef: + resolver: git + params: + - name: url + value: https://github.com/tektoncd/catalog.git + - name: revision + value: main + - name: pathInRepo + value: task/git-clone/0.9/git-clone.yaml + params: + - name: url + value: $(params.git-repo) + - name: revision + value: $(params.git-revision) + workspaces: + - name: output + workspace: shared + - name: build + runAfter: + - clone + taskRef: + resolver: git + params: + - name: url + value: https://github.com/tektoncd/catalog.git + - name: revision + value: main + - name: pathInRepo + value: task/buildah/0.5/buildah.yaml + params: + - name: IMAGE + value: $(params.output-image) + workspaces: + - name: source + workspace: shared + - name: sbom + runAfter: + - build + taskRef: + kind: Task + name: sbom + params: + - name: IMAGE_URL + value: $(params.output-image) + - name: IMAGE_DIGEST + value: $(tasks.build.results.IMAGE_DIGEST) + - name: SBOM_REPO + value: $(params.sbom-repo) + workspaces: + - name: shared diff --git a/examples/sbom/sbom-task.yaml b/examples/sbom/sbom-task.yaml new file mode 100644 index 0000000000..0eff308794 --- /dev/null +++ b/examples/sbom/sbom-task.yaml @@ -0,0 +1,70 @@ +# 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. + +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: sbom +spec: + params: + - name: IMAGE_URL + description: Reference to the image the SBOM will be generated for. + - name: IMAGE_DIGEST + description: Digest of the image the SBOM will be generated for. + - name: SBOM_REPO + description: OCI repo to temporarily push the SBOM blob to. + - name: HOMEDIR + type: string + description: Value for the HOME environment variable. + default: /tekton/home + results: + - name: IMAGE_URL + description: Reference to the image the SBOM will be generated for. + - name: IMAGE_DIGEST + description: Digest of the image the SBOM will be generated for. + - name: IMAGE_SBOM_URL + description: Reference, including digest, to the SBOM blob. + - name: IMAGE_SBOM_FORMAT + description: The SBOM format. + type: string + stepTemplate: + env: + - name: HOME + value: "$(params.HOMEDIR)" + steps: + - name: make-sbom + image: docker.io/anchore/syft:v0.86.1 + args: + - $(params.IMAGE_URL)@$(params.IMAGE_DIGEST) + - --output + - cyclonedx-json + - --file + - $(params.HOMEDIR)/sbom.json + - name: emit-results + image: docker.io/busybox:1.36 + script: | + sbom_digest="$(sha256sum $(params.HOMEDIR)/sbom.json | cut -d' ' -f1)" + echo -n "$(params.SBOM_REPO)@sha256:${sbom_digest}" | tee $(results.IMAGE_SBOM_URL.path) + echo -n 'https://cyclonedx.org/schema' | tee $(results.IMAGE_SBOM_FORMAT.path) + echo -n '$(params.IMAGE_URL)' | tee $(results.IMAGE_URL.path) + echo -n '$(params.IMAGE_DIGEST)' | tee $(results.IMAGE_DIGEST.path) + - name: store-sbom-blob + image: docker.io/bitnami/oras:latest + args: + - blob + - push + - --registry-config + - /tekton/creds/.docker/config.json + - $(params.SBOM_REPO) + - $(params.HOMEDIR)/sbom.json diff --git a/pkg/artifacts/sbom.go b/pkg/artifacts/sbom.go new file mode 100644 index 0000000000..308c50727d --- /dev/null +++ b/pkg/artifacts/sbom.go @@ -0,0 +1,148 @@ +/* +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 artifacts + +import ( + "context" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/tektoncd/chains/pkg/chains/objects" + "github.com/tektoncd/chains/pkg/config" + "k8s.io/apimachinery/pkg/util/sets" + "knative.dev/pkg/logging" +) + +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 hasSBOMRequirements(s StructuredSignable) bool { + return s.Digest != "" && s.URI != "" && s.SBOMURI != "" && s.SBOMFormat != "" +} + +func extractSBOMFromResults(ctx context.Context, tektonObject objects.TektonObject) []StructuredSignable { + logger := logging.FromContext(ctx) + var objs []StructuredSignable + + sse := structuredSignableExtractor{ + uriSuffix: "IMAGE_URL", + digestSuffix: "IMAGE_DIGEST", + sbomURISuffix: "IMAGE_SBOM_URL", + sbomFormatSuffix: "IMAGE_SBOM_FORMAT", + isValid: hasSBOMRequirements, + } + for _, s := range sse.extract(ctx, tektonObject) { + 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/sbom_test.go b/pkg/artifacts/sbom_test.go new file mode 100644 index 0000000000..7896b7d1c7 --- /dev/null +++ b/pkg/artifacts/sbom_test.go @@ -0,0 +1,482 @@ +/* +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 artifacts + +import ( + "fmt" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-containerregistry/pkg/name" + "github.com/tektoncd/chains/pkg/chains/objects" + "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + logtesting "knative.dev/pkg/logging/testing" +) + +func TestSBOMArtifactExtractObjects(t *testing.T) { + tests := []struct { + name string + obj objects.TektonObject + want []any + }{ + { + 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: []any{ + 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: []any{ + 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: []any{ + 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, + }, + } + + transformer := cmp.Transformer("sort_sbom", func(in []any) []string { + items := make([]string, 0, len(in)) + for _, item := range in { + var itemString string + sbomObject, ok := item.(*objects.SBOMObject) + if ok { + itemString = fmt.Sprintf( + "ImageDigest=%q\nImageURL=%q\nSBOMFormat=%q\nSBOMURL=%q", + sbomObject.GetImageDigest(), sbomObject.GetImageURL(), sbomObject.GetSBOMFormat(), + sbomObject.GetSBOMURL()) + } else { + // This shouldn't happen, but in case there's another []any value, perform a + // generic conversion. + itemString = fmt.Sprintf("%#v", item) + } + + items = append(items, itemString) + } + sort.Strings(items) + return items + }) + + 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 { + t.Fatal(err) + } + return result + +} diff --git a/pkg/artifacts/signable.go b/pkg/artifacts/signable.go index 33594ade1f..1ac9492f99 100644 --- a/pkg/artifacts/signable.go +++ b/pkg/artifacts/signable.go @@ -148,14 +148,6 @@ type image struct { digest string } -// StructuredSignable contains info for signable targets to become either subjects or materials in intoto Statements. -// 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 -} - func (oa *OCIArtifact) ExtractObjects(ctx context.Context, obj objects.TektonObject) []interface{} { log := logging.FromContext(ctx) objs := []interface{}{} @@ -204,11 +196,13 @@ 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") - for _, s := range ss { - if s == nil || s.Digest == "" || s.URI == "" { - continue - } + + extractor := structuredSignableExtractor{ + uriSuffix: "IMAGE_URL", + digestSuffix: "IMAGE_DIGEST", + isValid: hasImageRequirements, + } + for _, s := range extractor.extract(ctx, obj) { dgst, err := name.NewDigest(fmt.Sprintf("%s@%s", s.URI, s.Digest)) if err != nil { logger.Errorf("error getting digest: %v", err) @@ -217,6 +211,7 @@ func ExtractOCIImagesFromResults(ctx context.Context, obj objects.TektonObject) objs = append(objs, dgst) } + // look for a comma separated list of images for _, key := range obj.GetResults() { if key.Name != "IMAGES" { @@ -242,24 +237,23 @@ func ExtractOCIImagesFromResults(ctx context.Context, obj objects.TektonObject) } // ExtractSignableTargetFromResults extracts signable targets that aim to generate intoto provenance as materials within TaskRun results and store them as StructuredSignable. -func ExtractSignableTargetFromResults(ctx context.Context, obj objects.TektonObject) []*StructuredSignable { +func ExtractSignableTargetFromResults(ctx context.Context, obj objects.TektonObject) []StructuredSignable { logger := logging.FromContext(ctx) - objs := []*StructuredSignable{} - 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 == "" { - continue - } - if _, _, err := ParseDigest(s.Digest); err != nil { - logger.Errorf("error getting digest %s: %v", s.Digest, err) - continue - } - - objs = append(objs, s) + extractor := structuredSignableExtractor{ + uriSuffix: "ARTIFACT_URI", + digestSuffix: "ARTIFACT_DIGEST", + isValid: func(s StructuredSignable) bool { + if !hasImageRequirements(s) { + return false + } + if _, _, err := ParseDigest(s.Digest); err != nil { + logger.Errorf("error getting digest %s: %v", s.Digest, err) + return false + } + return true + }, } - - return objs + return extractor.extract(ctx, obj) } // FullRef returns the full reference of the signable artifact in the format of URI@DIGEST @@ -267,42 +261,6 @@ 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 { - logger := logging.FromContext(ctx) - ss := map[string]*StructuredSignable{} - - for _, res := range obj.GetResults() { - if strings.HasSuffix(res.Name, identifierSuffix) { - if res.Value.StringVal == "" { - logger.Debugf("error getting string value for %s", res.Name) - continue - } - marker := strings.TrimSuffix(res.Name, identifierSuffix) - if v, ok := ss[marker]; ok { - v.URI = strings.TrimSpace(res.Value.StringVal) - - } else { - ss[marker] = &StructuredSignable{URI: strings.TrimSpace(res.Value.StringVal)} - } - // TODO: add logic for Intoto signable target as input. - } - if strings.HasSuffix(res.Name, digestSuffix) { - if res.Value.StringVal == "" { - logger.Debugf("error getting string value for %s", res.Name) - continue - } - marker := strings.TrimSuffix(res.Name, digestSuffix) - if v, ok := ss[marker]; ok { - v.Digest = strings.TrimSpace(res.Value.StringVal) - } else { - ss[marker] = &StructuredSignable{Digest: strings.TrimSpace(res.Value.StringVal)} - } - } - - } - return ss -} - // RetrieveMaterialsFromStructuredResults retrieves structured results from Tekton Object, and convert them into materials. func RetrieveMaterialsFromStructuredResults(ctx context.Context, obj objects.TektonObject, categoryMarker string) []common.ProvenanceMaterial { logger := logging.FromContext(ctx) @@ -437,3 +395,7 @@ func (oa *OCIArtifact) FullKey(obj interface{}) string { func (oa *OCIArtifact) Enabled(cfg config.Config) bool { return cfg.Artifacts.OCI.Enabled() } + +func hasImageRequirements(s StructuredSignable) bool { + return s.URI != "" && s.Digest != "" +} diff --git a/pkg/artifacts/signable_test.go b/pkg/artifacts/signable_test.go index 6f1be4a6ca..306f3b30ca 100644 --- a/pkg/artifacts/signable_test.go +++ b/pkg/artifacts/signable_test.go @@ -370,7 +370,7 @@ func TestExtractSignableTargetFromResults(t *testing.T) { }, }, } - want := []*StructuredSignable{ + want := []StructuredSignable{ {URI: "projects/test-project/locations/us-west4/repositories/test-repo/mavenArtifacts/com.google.guava:guava:31.0-jre", Digest: digest1}, {URI: "com.google.guava:guava:31.0-jre.pom", Digest: digest2}, {URI: "com.google.guava:guava:31.0-jre-sources.jar", Digest: digest3}, @@ -637,12 +637,3 @@ func TestValidateResults(t *testing.T) { }) } } - -func createDigest(t *testing.T, dgst string) name.Digest { - result, err := name.NewDigest(dgst) - if err != nil { - t.Fatal(err) - } - return result - -} diff --git a/pkg/artifacts/structured.go b/pkg/artifacts/structured.go new file mode 100644 index 0000000000..086855d212 --- /dev/null +++ b/pkg/artifacts/structured.go @@ -0,0 +1,98 @@ +/* +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 artifacts + +import ( + "context" + "strings" + + "github.com/tektoncd/chains/pkg/chains/objects" + "knative.dev/pkg/logging" +) + +// StructuredSignable contains info for signable targets to become either subjects or materials in +// intoto Statements. They are also used in SBOM signing. +// URI is the resource uri for the target needed iff the target is a material. +// Digest is the target's SHA digest. +// SBOMURI is the resource uri for the target's SBOM . +// SBOMFormat is the format of the target's SBOM. +type StructuredSignable struct { + URI string + Digest string + SBOMURI string + SBOMFormat string +} + +type structuredSignableExtractor struct { + uriSuffix string + digestSuffix string + sbomURISuffix string + sbomFormatSuffix string + isValid func(StructuredSignable) bool +} + +func (b *structuredSignableExtractor) extract(ctx context.Context, obj objects.TektonObject) []StructuredSignable { + logger := logging.FromContext(ctx) + partials := map[string]StructuredSignable{} + + suffixes := map[string]func(StructuredSignable, string) StructuredSignable{ + b.uriSuffix: func(s StructuredSignable, value string) StructuredSignable { + s.URI = value + return s + }, + b.digestSuffix: func(s StructuredSignable, value string) StructuredSignable { + s.Digest = value + return s + }, + b.sbomURISuffix: func(s StructuredSignable, value string) StructuredSignable { + s.SBOMURI = value + return s + }, + b.sbomFormatSuffix: func(s StructuredSignable, value string) StructuredSignable { + s.SBOMFormat = value + return s + }, + } + + for _, res := range obj.GetResults() { + for suffix, setFn := range suffixes { + if suffix == "" { + continue + } + if !strings.HasSuffix(res.Name, suffix) { + continue + } + value := strings.TrimSpace(res.Value.StringVal) + if value == "" { + logger.Debugf("error getting string value for %s", res.Name) + continue + } + marker := strings.TrimSuffix(res.Name, suffix) + if _, ok := partials[marker]; !ok { + partials[marker] = StructuredSignable{} + } + partials[marker] = setFn(partials[marker], value) + } + } + + var signables []StructuredSignable + for _, s := range partials { + if !b.isValid(s) { + continue + } + signables = append(signables, s) + } + + return signables +} diff --git a/pkg/chains/formats/format.go b/pkg/chains/formats/format.go index 6c75a5866a..0a9e4dcb63 100644 --- a/pkg/chains/formats/format.go +++ b/pkg/chains/formats/format.go @@ -47,7 +47,7 @@ var ( ) // PayloaderInit initializes a new Payloader instance for the given config. -type PayloaderInit func(config.Config) (Payloader, error) +type PayloaderInit func(context.Context, config.Config) (Payloader, error) // RegisterPayloader registers the PayloaderInit func for the given type. // This is suitable to be calling during init() to register Payloader types. @@ -57,10 +57,10 @@ func RegisterPayloader(key config.PayloadType, init PayloaderInit) { // GetPayloader returns a new Payloader of the given type. // If no Payloader is registered for the type, an error is returned. -func GetPayloader(key config.PayloadType, cfg config.Config) (Payloader, error) { +func GetPayloader(ctx context.Context, key config.PayloadType, cfg config.Config) (Payloader, error) { fn, ok := payloaderMap[key] if !ok { return nil, fmt.Errorf("payloader %q not found", key) } - return fn(cfg) + return fn(ctx, cfg) } diff --git a/pkg/chains/formats/sbom/sbom.go b/pkg/chains/formats/sbom/sbom.go new file mode 100644 index 0000000000..2d28d1fe15 --- /dev/null +++ b/pkg/chains/formats/sbom/sbom.go @@ -0,0 +1,95 @@ +/* +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" +) + +func GenerateAttestation(ctx context.Context, kc kubernetes.Interface, builderID string, maxBytes int64, sbom *objects.SBOMObject) (interface{}, error) { + subject := []intoto.Subject{ + {Name: sbom.GetImageURL(), Digest: toDigestSet(sbom.GetImageDigest())}, + } + + data, err := getData(ctx, kc, maxBytes, 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, maxBytes int64, sbom *objects.SBOMObject) (json.RawMessage, error) { + opt, err := sbom.OCIRemoteOption(ctx, kc) + if err != nil { + return nil, err + } + + uri := sbom.GetSBOMURL() + ref, err := name.NewDigest(uri) + if err != nil { + return nil, err + } + + rawLayer, err := remote.Layer(ref, opt) + if err != nil { + return nil, err + } + + layer, err := rawLayer.Uncompressed() + if err != nil { + return nil, err + } + defer layer.Close() + + var blob bytes.Buffer + if _, err := io.Copy(&blob, io.LimitReader(layer, maxBytes)); err != nil { + return nil, err + } + + var data json.RawMessage + if err := json.Unmarshal(blob.Bytes(), &data); err != nil { + return nil, fmt.Errorf("SBOM is not valid JSON or is too large: %w", 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..00c72145c9 --- /dev/null +++ b/pkg/chains/formats/sbom/sbom_test.go @@ -0,0 +1,164 @@ +/* +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" + "io" + "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/google/go-containerregistry/pkg/v1/stream" + "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" + "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: testSBOMRepo, + 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 + imageURL := fmt.Sprintf(c.imageURL, registryURL.Host) + + // Create SBOM image. + sbomURL, err := pushSBOMLayer(fmt.Sprintf(c.sbomURL, registryURL.Host), c.sbom) + if err != nil { + t.Fatal(err) + } + + // Setup SBOM Object + sbomObject := objects.NewSBOMObject( + sbomURL.String(), + c.sbomFormat, + imageURL, + c.imageDigest, tektonObject) + + var maxSBOMBytes int64 = 10 * 1024 * 1024 + got, err := GenerateAttestation(ctx, kc, testBuilderID, maxSBOMBytes, 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 ( + testSBOMRepo = "%s/foo/bat" + testSBOMFormat = "https://cyclonedx.org/schema" + testSBOMMediaType = "application/octet-stream" + testImageURL = "%s/foo/bat:latest" + testImageDigestNoAlgo = "05f95b26ed10668b7183c1e2da98610e91372fa9f510046d4ce5812addad86b6" + testImageDigest = "sha256:" + testImageDigestNoAlgo + testBuilderID = "test-builder-id" +) + +func pushSBOMLayer(ref string, data string) (name.Digest, error) { + layer := stream.NewLayer(io.NopCloser(strings.NewReader(data))) + + repo, err := name.NewRepository(ref) + if err != nil { + return name.Digest{}, err + } + + if err := remote.WriteLayer(repo, layer); err != nil { + return name.Digest{}, err + } + + digest, err := layer.Digest() + if err != nil { + return name.Digest{}, err + } + + return repo.Digest(digest.String()), nil +} diff --git a/pkg/chains/formats/simple/simple.go b/pkg/chains/formats/simple/simple.go index 10c464f96a..604102bb2e 100644 --- a/pkg/chains/formats/simple/simple.go +++ b/pkg/chains/formats/simple/simple.go @@ -39,7 +39,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(_ context.Context, obj interface{}) (interface{}, error) { switch v := obj.(type) { case name.Digest: format := NewSimpleStruct(v) @@ -53,7 +53,7 @@ func (i *SimpleSigning) Wrap() bool { return false } -func NewFormatter(config.Config) (formats.Payloader, error) { +func NewFormatter(context.Context, config.Config) (formats.Payloader, error) { return &SimpleSigning{}, nil } diff --git a/pkg/chains/formats/slsa/v1/intotoite6.go b/pkg/chains/formats/slsa/v1/intotoite6.go index 4ab3c8d0bf..3849879753 100644 --- a/pkg/chains/formats/slsa/v1/intotoite6.go +++ b/pkg/chains/formats/slsa/v1/intotoite6.go @@ -21,11 +21,13 @@ 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/internal/slsaconfig" "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" + kubeclient "knative.dev/pkg/client/injection/kube/client" ) const ( @@ -39,11 +41,17 @@ func init() { } type InTotoIte6 struct { - slsaConfig *slsaconfig.SlsaConfig + appContext context.Context + builderID string + sbomMaxBytes int64 + slsaConfig *slsaconfig.SlsaConfig } -func NewFormatter(cfg config.Config) (formats.Payloader, error) { +func NewFormatter(ctx context.Context, cfg config.Config) (formats.Payloader, error) { return &InTotoIte6{ + appContext: ctx, + builderID: cfg.Builder.ID, + sbomMaxBytes: cfg.Artifacts.SBOM.MaxBytes, slsaConfig: &slsaconfig.SlsaConfig{ BuilderID: cfg.Builder.ID, DeepInspectionEnabled: cfg.Artifacts.PipelineRuns.DeepInspectionEnabled, @@ -61,6 +69,12 @@ func (i *InTotoIte6) CreatePayload(ctx context.Context, obj interface{}) (interf return taskrun.GenerateAttestation(ctx, v, i.slsaConfig) case *objects.PipelineRunObject: return pipelinerun.GenerateAttestation(ctx, v, i.slsaConfig) + 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. + kc := kubeclient.Get(i.appContext) + // TODO: Use SLSAConfig (or some variation of it? SBOMConfig?) + return sbom.GenerateAttestation(ctx, kc, i.builderID, i.sbomMaxBytes, 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 a61bf2489b..cb7dd89ea4 100644 --- a/pkg/chains/formats/slsa/v1/intotoite6_test.go +++ b/pkg/chains/formats/slsa/v1/intotoite6_test.go @@ -17,6 +17,7 @@ limitations under the License. package v1 import ( + "context" "testing" "time" @@ -133,7 +134,7 @@ func TestTaskRunCreatePayload1(t *testing.T) { }, }, } - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) @@ -359,7 +360,7 @@ func TestPipelineRunCreatePayload(t *testing.T) { pro.AppendTaskRun(tr1) pro.AppendTaskRun(tr2) - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, pro) if err != nil { @@ -577,7 +578,7 @@ func TestPipelineRunCreatePayloadChildRefs(t *testing.T) { pro.AppendTaskRun(tr1) pro.AppendTaskRun(tr2) - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, pro) if err != nil { t.Errorf("unexpected error: %s", err.Error()) @@ -652,7 +653,7 @@ func TestTaskRunCreatePayload2(t *testing.T) { }, }, } - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) if err != nil { @@ -723,7 +724,7 @@ func TestMultipleSubjects(t *testing.T) { }, } - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) if err != nil { t.Errorf("unexpected error: %s", err.Error()) @@ -740,7 +741,7 @@ func TestNewFormatter(t *testing.T) { ID: "testid", }, } - f, err := NewFormatter(cfg) + f, err := NewFormatter(appContext(), cfg) if f == nil { t.Error("Failed to create formatter") } @@ -758,7 +759,7 @@ func TestCreatePayloadError(t *testing.T) { ID: "testid", }, } - f, _ := NewFormatter(cfg) + f, _ := NewFormatter(appContext(), cfg) t.Run("Invalid type", func(t *testing.T) { p, err := f.CreatePayload(ctx, "not a task ref") @@ -783,3 +784,7 @@ func TestCorrectPayloadType(t *testing.T) { t.Errorf("Invalid type returned: %s", i.Type()) } } + +func appContext() context.Context { + return context.Background() +} diff --git a/pkg/chains/formats/slsa/v2alpha1/slsav2.go b/pkg/chains/formats/slsa/v2alpha1/slsav2.go index bb7e1b68ee..efa1b7fa37 100644 --- a/pkg/chains/formats/slsa/v2alpha1/slsav2.go +++ b/pkg/chains/formats/slsa/v2alpha1/slsav2.go @@ -38,7 +38,7 @@ type Slsa struct { builderID string } -func NewFormatter(cfg config.Config) (formats.Payloader, error) { +func NewFormatter(_ context.Context, cfg config.Config) (formats.Payloader, error) { return &Slsa{ builderID: cfg.Builder.ID, }, nil diff --git a/pkg/chains/formats/slsa/v2alpha1/slsav2_test.go b/pkg/chains/formats/slsa/v2alpha1/slsav2_test.go index c06e126ca7..ea772f2656 100644 --- a/pkg/chains/formats/slsa/v2alpha1/slsav2_test.go +++ b/pkg/chains/formats/slsa/v2alpha1/slsav2_test.go @@ -17,6 +17,7 @@ limitations under the License. package v2alpha1 import ( + "context" "testing" "time" @@ -160,7 +161,7 @@ func TestTaskRunCreatePayload1(t *testing.T) { }, }, } - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) @@ -262,7 +263,7 @@ func TestTaskRunCreatePayload2(t *testing.T) { }, }, } - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) if err != nil { @@ -360,7 +361,7 @@ func TestMultipleSubjects(t *testing.T) { }, } - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) if err != nil { t.Errorf("unexpected error: %s", err.Error()) @@ -377,7 +378,7 @@ func TestNewFormatter(t *testing.T) { ID: "testid", }, } - f, err := NewFormatter(cfg) + f, err := NewFormatter(appContext(), cfg) if f == nil { t.Error("Failed to create formatter") } @@ -395,7 +396,7 @@ func TestCreatePayloadError(t *testing.T) { ID: "testid", }, } - f, _ := NewFormatter(cfg) + f, _ := NewFormatter(appContext(), cfg) t.Run("Invalid type", func(t *testing.T) { p, err := f.CreatePayload(ctx, "not a task ref") @@ -420,3 +421,7 @@ func TestCorrectPayloadType(t *testing.T) { t.Errorf("Invalid type returned: %s", i.Type()) } } + +func appContext() context.Context { + return context.Background() +} diff --git a/pkg/chains/formats/slsa/v2alpha2/slsav2.go b/pkg/chains/formats/slsa/v2alpha2/slsav2.go index 688afa2ba0..8c806bd2be 100644 --- a/pkg/chains/formats/slsa/v2alpha2/slsav2.go +++ b/pkg/chains/formats/slsa/v2alpha2/slsav2.go @@ -40,7 +40,7 @@ type Slsa struct { slsaConfig *slsaconfig.SlsaConfig } -func NewFormatter(cfg config.Config) (formats.Payloader, error) { +func NewFormatter(_ context.Context, cfg config.Config) (formats.Payloader, error) { return &Slsa{ slsaConfig: &slsaconfig.SlsaConfig{ BuilderID: cfg.Builder.ID, diff --git a/pkg/chains/formats/slsa/v2alpha2/slsav2_test.go b/pkg/chains/formats/slsa/v2alpha2/slsav2_test.go index 38c0107ca9..2ec055f3b1 100644 --- a/pkg/chains/formats/slsa/v2alpha2/slsav2_test.go +++ b/pkg/chains/formats/slsa/v2alpha2/slsav2_test.go @@ -17,6 +17,7 @@ limitations under the License. package v2alpha2 import ( + "context" "encoding/json" "testing" "time" @@ -49,7 +50,7 @@ func TestNewFormatter(t *testing.T) { ID: "testid", }, } - f, err := NewFormatter(cfg) + f, err := NewFormatter(appContext(), cfg) if err != nil { t.Errorf("Error creating formatter: %s", err) } @@ -67,7 +68,7 @@ func TestCreatePayloadError(t *testing.T) { ID: "testid", }, } - f, _ := NewFormatter(cfg) + f, _ := NewFormatter(appContext(), cfg) t.Run("Invalid type", func(t *testing.T) { p, err := f.CreatePayload(ctx, "not a task ref") @@ -185,7 +186,7 @@ func TestTaskRunCreatePayload1(t *testing.T) { }, } - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) @@ -271,7 +272,7 @@ func TestTaskRunCreatePayload2(t *testing.T) { }, } - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) if err != nil { @@ -351,7 +352,7 @@ func TestMultipleSubjects(t *testing.T) { }, } - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, objects.NewTaskRunObject(tr)) if err != nil { t.Errorf("unexpected error: %s", err.Error()) @@ -490,7 +491,7 @@ func TestPipelineRunCreatePayload1(t *testing.T) { }, } - i, _ := NewFormatter(cfg) + i, _ := NewFormatter(appContext(), cfg) got, err := i.CreatePayload(ctx, pr) @@ -501,3 +502,7 @@ func TestPipelineRunCreatePayload1(t *testing.T) { t.Errorf("Slsa.CreatePayload(): -want +got: %s", diff) } } + +func appContext() context.Context { + return context.Background() +} diff --git a/pkg/chains/objects/objects.go b/pkg/chains/objects/objects.go index e81e3578d4..88ce6628de 100644 --- a/pkg/chains/objects/objects.go +++ b/pkg/chains/objects/objects.go @@ -19,12 +19,15 @@ import ( "fmt" "strings" + "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" ) @@ -65,6 +68,7 @@ type TektonObject interface { SupportsTaskRunArtifact() bool SupportsPipelineRunArtifact() bool SupportsOCIArtifact() bool + SupportsSBOMArtifact() bool } func NewTektonObject(i interface{}) (TektonObject, error) { @@ -152,6 +156,10 @@ func (tro *TaskRunObject) SupportsOCIArtifact() bool { return true } +func (tro *TaskRunObject) SupportsSBOMArtifact() bool { + return true +} + // PipelineRunObject extends v1beta1.PipelineRun with additional functions. type PipelineRunObject struct { // The base PipelineRun @@ -250,6 +258,10 @@ func (pro *PipelineRunObject) SupportsOCIArtifact() bool { return false } +func (tro *PipelineRunObject) SupportsSBOMArtifact() bool { + return false +} + // Get the imgPullSecrets from a pod template, if they exist func getPodPullSecrets(podTemplate *pod.Template) []string { imgPullSecrets := []string{} @@ -260,3 +272,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 0d5e3ad45a..025aaa58a0 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 { @@ -205,6 +208,36 @@ 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) +} + func TestPipelineRun_GetGVK(t *testing.T) { assert.Equal(t, "tekton.dev/v1beta1/PipelineRun", NewPipelineRunObject(getPipelineRun()).GetGVK()) } diff --git a/pkg/chains/signing.go b/pkg/chains/signing.go index d6c172eacc..1c1bd7869b 100644 --- a/pkg/chains/signing.go +++ b/pkg/chains/signing.go @@ -35,6 +35,7 @@ import ( type Signer interface { Sign(ctx context.Context, obj objects.TektonObject) error + AppContext() context.Context } type ObjectSigner struct { @@ -44,6 +45,15 @@ type ObjectSigner struct { Backends map[string]storage.Backend SecretPath string Pipelineclientset versioned.Interface + appContext context.Context +} + +func ObjectSignerWithContext(ctx context.Context) *ObjectSigner { + return &ObjectSigner{appContext: ctx} +} + +func (o *ObjectSigner) AppContext() context.Context { + return o.appContext } func allSigners(ctx context.Context, sp string, cfg config.Config) map[string]signing.Signer { @@ -98,6 +108,10 @@ func getSignableTypes(ctx context.Context, obj objects.TektonObject) ([]artifact types = append(types, &artifacts.OCIArtifact{}) } + if obj.SupportsSBOMArtifact() { + types = append(types, &artifacts.SBOMArtifact{}) + } + if len(types) == 0 { return nil, fmt.Errorf("no signable artifacts found for %v", obj) } @@ -126,7 +140,7 @@ func (o *ObjectSigner) Sign(ctx context.Context, tektonObj objects.TektonObject) } payloadFormat := signableType.PayloadFormat(cfg) // Find the right payload format and format the object - payloader, err := formats.GetPayloader(payloadFormat, cfg) + payloader, err := formats.GetPayloader(o.AppContext(), payloadFormat, cfg) if err != nil { logger.Warnf("Format %s configured for %s: %v was not found", payloadFormat, tektonObj.GetGVK(), signableType.Type()) continue @@ -138,7 +152,6 @@ 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) if err != nil { logger.Error(err) diff --git a/pkg/config/config.go b/pkg/config/config.go index 5e936adf8c..77009b9418 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 SBOMArtifact } // Artifact contains the configuration for how to sign/store/format the signatures for a single artifact @@ -50,6 +51,12 @@ type Artifact struct { DeepInspectionEnabled bool } +// SBOMArtifact contains the configuration for how to sign/store/format SBOM artifacts +type SBOMArtifact struct { + Artifact + MaxBytes int64 +} + // StorageConfigs contains the configuration to instantiate different storage providers type StorageConfigs struct { GCS GCSStorageConfig @@ -160,6 +167,11 @@ const ( ociStorageKey = "artifacts.oci.storage" ociSignerKey = "artifacts.oci.signer" + sbomFormatKey = "artifacts.sbom.format" + sbomStorageKey = "artifacts.sbom.storage" + sbomSignerKey = "artifacts.sbom.signer" + sbomMaxBytesKey = "artifacts.sbom.maxbytes" + gcsBucketKey = "storage.gcs.bucket" ociRepositoryKey = "storage.oci.repository" ociRepositoryInsecureKey = "storage.oci.repository.insecure" @@ -207,6 +219,9 @@ func (artifact *Artifact) Enabled() bool { return !(artifact.StorageBackend.Len() == 1 && artifact.StorageBackend.Has("")) } +// 10 Megabytes +const defaultSBOMMaxBytes = 10 * 1024 * 1024 + func defaultConfig() *Config { return &Config{ Artifacts: ArtifactConfigs{ @@ -226,6 +241,14 @@ func defaultConfig() *Config { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, }, Transparency: TransparencyConfig{ URL: "https://rekor.sigstore.dev", @@ -270,6 +293,12 @@ 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"), + asInt64(sbomMaxBytesKey, &cfg.Artifacts.SBOM.MaxBytes, defaultSBOMMaxBytes), + // PubSub - General asString(pubsubProvider, &cfg.Storage.PubSub.Provider, "inmemory", "kafka"), asString(pubsubTopic, &cfg.Storage.PubSub.Topic), @@ -356,6 +385,22 @@ func asBool(key string, target *bool) cm.ParseFunc { } } +func asInt64(key string, target *int64, defaultValue int64) cm.ParseFunc { + return func(data map[string]string) error { + *target = defaultValue + raw, ok := data[key] + if !ok { + return nil + } + val, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil + } + *target = val + return nil + } +} + // asString passes the value at key through into the target, if it exists. // TODO(mattmoor): This might be a nice variation on cm.AsString to upstream. func asString(key string, target *string, values ...string) cm.ParseFunc { diff --git a/pkg/config/store_test.go b/pkg/config/store_test.go index b10e3fedad..7f0aea5923 100644 --- a/pkg/config/store_test.go +++ b/pkg/config/store_test.go @@ -113,6 +113,14 @@ var defaultArtifacts = ArtifactConfigs{ StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, } var defaultStorage = StorageConfigs{ @@ -130,6 +138,7 @@ func TestParse(t *testing.T) { name string data map[string]string taskrunEnabled bool + sbomEnabled bool ociEnbaled bool want Config }{ @@ -138,6 +147,7 @@ func TestParse(t *testing.T) { data: map[string]string{}, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: defaultArtifacts, @@ -152,6 +162,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: BuilderConfig{ "builder-id-test", @@ -168,6 +179,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: defaultArtifacts, @@ -185,6 +197,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{ @@ -204,6 +217,14 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -215,6 +236,7 @@ func TestParse(t *testing.T) { data: map[string]string{pipelinerunStorageKey: "tekton,docdb"}, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -234,6 +256,14 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -245,6 +275,7 @@ func TestParse(t *testing.T) { data: map[string]string{taskrunStorageKey: ""}, taskrunEnabled: false, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -264,6 +295,14 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -275,6 +314,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{ @@ -294,6 +334,14 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci", "tekton"), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -305,6 +353,7 @@ func TestParse(t *testing.T) { data: map[string]string{ociStorageKey: ""}, taskrunEnabled: true, ociEnbaled: false, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -324,6 +373,14 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string](""), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -338,6 +395,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: false, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -357,6 +415,14 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string](""), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -371,6 +437,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: false, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -390,6 +457,99 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci", "tekton"), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, + }, + 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: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci", "tekton"), + Signer: "kms", + }, + MaxBytes: defaultSBOMMaxBytes, + }, + }, + Signers: defaultSigners, + Storage: defaultStorage, + Transparency: defaultTransparency, + }, + }, + { + name: "sbom disabled", + data: map[string]string{ + sbomStorageKey: "", + sbomFormatKey: "in-toto", + sbomSignerKey: "kms", + sbomMaxBytesKey: "99", + }, + 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: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string](""), + Signer: "kms", + }, + MaxBytes: 99, + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -401,6 +561,7 @@ func TestParse(t *testing.T) { data: map[string]string{taskrunSignerKey: "x509"}, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -420,6 +581,14 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -431,6 +600,7 @@ func TestParse(t *testing.T) { data: map[string]string{transparencyEnabledKey: "manual"}, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: defaultArtifacts, @@ -452,6 +622,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -471,6 +642,14 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, }, Signers: defaultSigners, Storage: defaultStorage, @@ -485,6 +664,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: ArtifactConfigs{ @@ -504,6 +684,14 @@ func TestParse(t *testing.T) { StorageBackend: sets.New[string]("oci"), Signer: "x509", }, + SBOM: SBOMArtifact{ + Artifact: Artifact{ + Format: "in-toto", + StorageBackend: sets.New[string]("oci"), + Signer: "x509", + }, + MaxBytes: defaultSBOMMaxBytes, + }, }, Signers: SignerConfigs{ X509: X509Signer{ @@ -523,6 +711,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: defaultArtifacts, @@ -546,6 +735,7 @@ func TestParse(t *testing.T) { }, taskrunEnabled: true, ociEnbaled: true, + sbomEnabled: true, want: Config{ Builder: defaultBuilder, Artifacts: defaultArtifacts, @@ -577,6 +767,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/internal/mocksigner/mocksigner.go b/pkg/internal/mocksigner/mocksigner.go index 69acbdd080..0f82b346ad 100644 --- a/pkg/internal/mocksigner/mocksigner.go +++ b/pkg/internal/mocksigner/mocksigner.go @@ -30,3 +30,7 @@ func (m *Signer) Sign(ctx context.Context, obj objects.TektonObject) error { m.Signed = true return nil } + +func (m *Signer) AppContext() context.Context { + return nil +} diff --git a/pkg/reconciler/pipelinerun/controller.go b/pkg/reconciler/pipelinerun/controller.go index bc3d7ad47e..e03c0d0276 100644 --- a/pkg/reconciler/pipelinerun/controller.go +++ b/pkg/reconciler/pipelinerun/controller.go @@ -41,10 +41,9 @@ func NewController(ctx context.Context, cmw configmap.Watcher) *controller.Impl kubeClient := kubeclient.Get(ctx) pipelineClient := pipelineclient.Get(ctx) - psSigner := &chains.ObjectSigner{ - SecretPath: SecretPath, - Pipelineclientset: pipelineClient, - } + psSigner := chains.ObjectSignerWithContext(ctx) + psSigner.SecretPath = SecretPath + psSigner.Pipelineclientset = pipelineClient c := &Reconciler{ PipelineRunSigner: psSigner, diff --git a/pkg/reconciler/taskrun/controller.go b/pkg/reconciler/taskrun/controller.go index dbbb1cdab9..2f15883b5e 100644 --- a/pkg/reconciler/taskrun/controller.go +++ b/pkg/reconciler/taskrun/controller.go @@ -37,10 +37,9 @@ func NewController(ctx context.Context, cmw configmap.Watcher) *controller.Impl kubeClient := kubeclient.Get(ctx) pipelineClient := pipelineclient.Get(ctx) - tsSigner := &chains.ObjectSigner{ - SecretPath: SecretPath, - Pipelineclientset: pipelineClient, - } + tsSigner := chains.ObjectSignerWithContext(ctx) + tsSigner.SecretPath = SecretPath + tsSigner.Pipelineclientset = pipelineClient c := &Reconciler{ TaskRunSigner: tsSigner,