Skip to content

Commit

Permalink
Add support for signing SBOMs
Browse files Browse the repository at this point in the history
The idea is a TaskRun produces an unsigned SBOM and pushes it to the
OCI registry. The reference to the SBOM is then added to the result
IMAGE_SBOM_URL. When Chains sees this result, it downloads the SBOM from
the OCI registry, signs it, then adds to the image attestations with an
SBOM-specific predicate.

This is also possible when using the IMAGES result, and the
corresponding SBOMS result. Both must have the exact number of items.

Signed-off-by: Luiz Carvalho <lucarval@redhat.com>
  • Loading branch information
lcarva committed Mar 7, 2023
1 parent 555cdda commit 2924f10
Show file tree
Hide file tree
Showing 7 changed files with 413 additions and 5 deletions.
121 changes: 116 additions & 5 deletions pkg/artifacts/signable.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,9 @@ type image struct {
// URI is the resource uri for the target needed iff the target is a material.
// Digest is the target's SHA digest.
type StructuredSignable struct {
URI string
Digest string
URI string
Digest string
SBOMURI string
}

func (oa *OCIArtifact) ExtractObjects(obj objects.TektonObject) []interface{} {
Expand Down Expand Up @@ -204,7 +205,7 @@ func (oa *OCIArtifact) ExtractObjects(obj objects.TektonObject) []interface{} {

func ExtractOCIImagesFromResults(obj objects.TektonObject, logger *zap.SugaredLogger) []interface{} {
objs := []interface{}{}
ss := extractTargetFromResults(obj, "IMAGE_URL", "IMAGE_DIGEST", logger)
ss := extractTargetFromResults(obj, "IMAGE_URL", "IMAGE_DIGEST", "", logger)
for _, s := range ss {
if s == nil || s.Digest == "" || s.URI == "" {
continue
Expand Down Expand Up @@ -244,7 +245,7 @@ func ExtractOCIImagesFromResults(obj objects.TektonObject, logger *zap.SugaredLo
// ExtractSignableTargetFromResults extracts signable targets that aim to generate intoto provenance as materials within TaskRun results and store them as StructuredSignable.
func ExtractSignableTargetFromResults(obj objects.TektonObject, logger *zap.SugaredLogger) []*StructuredSignable {
objs := []*StructuredSignable{}
ss := extractTargetFromResults(obj, "ARTIFACT_URI", "ARTIFACT_DIGEST", logger)
ss := extractTargetFromResults(obj, "ARTIFACT_URI", "ARTIFACT_DIGEST", "", logger)
// Only add it if we got both the signable URI and digest.
for _, s := range ss {
if s == nil || s.Digest == "" || s.URI == "" {
Expand All @@ -266,7 +267,7 @@ func (s *StructuredSignable) FullRef() string {
return fmt.Sprintf("%s@%s", s.URI, s.Digest)
}

func extractTargetFromResults(obj objects.TektonObject, identifierSuffix string, digestSuffix string, logger *zap.SugaredLogger) map[string]*StructuredSignable {
func extractTargetFromResults(obj objects.TektonObject, identifierSuffix, digestSuffix, sbomSuffix string, logger *zap.SugaredLogger) map[string]*StructuredSignable {
ss := map[string]*StructuredSignable{}

for _, res := range obj.GetResults() {
Expand Down Expand Up @@ -296,6 +297,18 @@ func extractTargetFromResults(obj objects.TektonObject, identifierSuffix string,
ss[marker] = &StructuredSignable{Digest: strings.TrimSpace(res.Value.StringVal)}
}
}
if sbomSuffix != "" && strings.HasSuffix(res.Name, sbomSuffix) {
if res.Value.StringVal == "" {
logger.Debugf("error getting string value for %s", res.Name)
continue
}
marker := strings.TrimSuffix(res.Name, sbomSuffix)
if v, ok := ss[marker]; ok {
v.SBOMURI = strings.TrimSpace(res.Value.StringVal)
} else {
ss[marker] = &StructuredSignable{SBOMURI: strings.TrimSpace(res.Value.StringVal)}
}
}

}
return ss
Expand Down Expand Up @@ -434,3 +447,101 @@ func (oa *OCIArtifact) FullKey(obj interface{}) string {
func (oa *OCIArtifact) Enabled(cfg config.Config) bool {
return cfg.Artifacts.OCI.Enabled()
}

type SBOMArtifact struct {
Logger *zap.SugaredLogger
}

var _ Signable = &SBOMArtifact{}

func (sa *SBOMArtifact) ExtractObjects(tektonObject objects.TektonObject) []interface{} {
var objs []interface{}
for _, obj := range extractSBOMFromResults(tektonObject, sa.Logger) {
objs = append(objs, objects.NewSBOMObject(obj.SBOMURI, obj.URI, obj.Digest))
}
return objs
}

func (sa *SBOMArtifact) StorageBackend(cfg config.Config) sets.String {
return cfg.Artifacts.SBOM.StorageBackend
}
func (sa *SBOMArtifact) Signer(cfg config.Config) string {
return cfg.Artifacts.SBOM.Signer
}
func (sa *SBOMArtifact) PayloadFormat(cfg config.Config) config.PayloadType {
return config.PayloadType(cfg.Artifacts.SBOM.Format)
}

func (sa *SBOMArtifact) FullKey(obj interface{}) string {
v := obj.(*objects.SBOMObject)
return v.GetSBOMURL()
}

func (sa *SBOMArtifact) ShortKey(obj interface{}) string {
v := obj.(*objects.SBOMObject)
return v.GetSBOMURL()
}
func (sa *SBOMArtifact) Type() string {
return "sbom"
}
func (sa *SBOMArtifact) Enabled(cfg config.Config) bool {
return cfg.Artifacts.SBOM.Enabled()
}

func extractSBOMFromResults(tektonObject objects.TektonObject, logger *zap.SugaredLogger) []*StructuredSignable {
var objs []*StructuredSignable

// TODO: Maybe run each SBOM through name.NewDigest(trimmed) to ensure it is a digest ref?
ss := extractTargetFromResults(tektonObject, "IMAGE_URL", "IMAGE_DIGEST", "IMAGE_SBOM_URL", logger)
for _, s := range ss {
if s == nil || s.Digest == "" || s.URI == "" || s.SBOMURI == "" {
continue
}
objs = append(objs, s)
}

var images []name.Digest
var sboms []string
// look for a comma separated list of images
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
}
// TODO: Maybe run each SBOM through name.NewDigest(trimmed) to ensure it is a digest ref?
sboms = append(sboms, trimmed)
}
}
}

if len(images) != len(sboms) {
logger.Warnf("IMAGES and SBOMS do not contain the same amount of entries")
} else {
for i, sbom := range sboms {
img := images[i]
objs = append(objs, &StructuredSignable{
URI: img.Name(),
Digest: img.Identifier(),
SBOMURI: sbom,
})
}
}
return objs
}
103 changes: 103 additions & 0 deletions pkg/chains/formats/sbom/sbom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
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"
"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"
"go.uber.org/zap"
)

const maxSBOMBytes = 5 * 1024 * 1024 // 5 Megabytes

func GenerateAttestation(builderID string, sbom *objects.SBOMObject, logger *zap.SugaredLogger) (interface{}, error) {
subject := []intoto.Subject{
{Name: sbom.GetImageURL(), Digest: toDigestSet(sbom.GetImageDigest())},
}

data, err := getData(sbom.GetSBOMURL())
if err != nil {
return nil, err
}

// TODO: This needs to be configurable, obviously. Maybe it can
// be auto-detected from the SBOM data?
att := intoto.CycloneDXStatement{
StatementHeader: intoto.StatementHeader{
Type: intoto.StatementInTotoV01,
PredicateType: intoto.PredicateCycloneDX,
Subject: subject,
},
Predicate: SBOMPredicate{
Data: data,
},
}
return att, nil
}

type SBOMPredicate struct {
Data string `json:"Data"`
}

func getData(uri string) (string, error) {
// TODO: remote options so it works against a private repository?
ref, err := name.ParseReference(uri)
if err != nil {
return "", err
}

image, err := remote.Image(ref)
if err != nil {
return "", err
}

layers, err := image.Layers()
if err != nil {
return "", err
}

if len(layers) != 1 {
return "", fmt.Errorf("expected exactly 1 layer in sbom image %s, found %d", uri, len(layers))
}

layer, err := layers[0].Uncompressed()
if err != nil {
return "", err
}
defer layer.Close()

var data bytes.Buffer
if _, err := io.Copy(&data, io.LimitReader(layer, maxSBOMBytes)); err != nil {
return "", err
}

return data.String(), nil
}

func toDigestSet(digest string) slsa.DigestSet {
algo, value, found := strings.Cut(digest, ":")
if !found {
value = algo
algo = "sha256"
}
return slsa.DigestSet{algo: value}
}
5 changes: 5 additions & 0 deletions pkg/chains/formats/slsa/v1/intotoite6.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"

"github.com/tektoncd/chains/pkg/chains/formats"
"github.com/tektoncd/chains/pkg/chains/formats/sbom"
"github.com/tektoncd/chains/pkg/chains/formats/slsa/v1/pipelinerun"
"github.com/tektoncd/chains/pkg/chains/formats/slsa/v1/taskrun"
"github.com/tektoncd/chains/pkg/chains/objects"
Expand Down Expand Up @@ -59,6 +60,10 @@ func (i *InTotoIte6) CreatePayload(ctx context.Context, obj interface{}) (interf
return taskrun.GenerateAttestation(i.builderID, v, logger)
case *objects.PipelineRunObject:
return pipelinerun.GenerateAttestation(i.builderID, v, logger)
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(i.builderID, v, logger)
default:
return nil, fmt.Errorf("intoto does not support type: %s", v)
}
Expand Down
26 changes: 26 additions & 0 deletions pkg/chains/objects/objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,29 @@ func getPodPullSecrets(podTemplate *pod.Template) []string {
}
return imgPullSecrets
}

type SBOMObject struct {
sbomURL string
imageURL string
imageDigest string
}

func NewSBOMObject(sbomURL, imageURL, imageDigest string) *SBOMObject {
return &SBOMObject{
sbomURL: sbomURL,
imageURL: imageURL,
imageDigest: imageDigest,
}
}

func (so *SBOMObject) GetSBOMURL() string {
return so.sbomURL
}

func (so *SBOMObject) GetImageURL() string {
return so.imageURL
}

func (so *SBOMObject) GetImageDigest() string {
return so.imageDigest
}
1 change: 1 addition & 0 deletions pkg/chains/signing.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func getSignableTypes(obj objects.TektonObject, logger *zap.SugaredLogger) ([]ar
return []artifacts.Signable{
&artifacts.TaskRunArtifact{Logger: logger},
&artifacts.OCIArtifact{Logger: logger},
&artifacts.SBOMArtifact{Logger: logger},
}, nil
case *v1beta1.PipelineRun:
return []artifacts.Signable{
Expand Down
15 changes: 15 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type ArtifactConfigs struct {
OCI Artifact
PipelineRuns Artifact
TaskRuns Artifact
SBOM Artifact
}

// Artifact contains the configuration for how to sign/store/format the signatures for a single artifact
Expand Down Expand Up @@ -155,6 +156,10 @@ const (
ociStorageKey = "artifacts.oci.storage"
ociSignerKey = "artifacts.oci.signer"

sbomFormatKey = "artifacts.sbom.format"
sbomStorageKey = "artifacts.sbom.storage"
sbomSignerKey = "artifacts.sbom.signer"

gcsBucketKey = "storage.gcs.bucket"
ociRepositoryKey = "storage.oci.repository"
ociRepositoryInsecureKey = "storage.oci.repository.insecure"
Expand Down Expand Up @@ -222,6 +227,11 @@ func defaultConfig() *Config {
StorageBackend: sets.NewString("oci"),
Signer: "x509",
},
SBOM: Artifact{
Format: "in-toto",
StorageBackend: sets.NewString("oci"),
Signer: "x509",
},
},
Transparency: TransparencyConfig{
URL: "https://rekor.sigstore.dev",
Expand Down Expand Up @@ -264,6 +274,11 @@ func NewConfigFromMap(data map[string]string) (*Config, error) {
asStringSet(ociStorageKey, &cfg.Artifacts.OCI.StorageBackend, sets.NewString("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.NewString("tekton", "oci", "gcs", "docdb", "grafeas", "kafka")),
asString(sbomSignerKey, &cfg.Artifacts.SBOM.Signer, "x509", "kms"),

// PubSub - General
asString(pubsubProvider, &cfg.Storage.PubSub.Provider, "inmemory", "kafka"),
asString(pubsubTopic, &cfg.Storage.PubSub.Topic),
Expand Down
Loading

0 comments on commit 2924f10

Please sign in to comment.