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. IMAGE_SBOM_FORMAT is used to specify the
predicate to list in the attestation.

This is also possible when using the IMAGES result, and the
corresponding SBOMS result. Both must have the exact number of items.
When using the SBOMS result, SBOMS_FORMAT is also required to specify
the SBOM format for all the items in SBOMS.

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

func (oa *OCIArtifact) ExtractObjects(obj objects.TektonObject) []interface{} {
Expand Down Expand Up @@ -204,7 +206,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 +246,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 +268,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, sbomFormatSuffix string, logger *zap.SugaredLogger) map[string]*StructuredSignable {
ss := map[string]*StructuredSignable{}

for _, res := range obj.GetResults() {
Expand Down Expand Up @@ -296,6 +298,31 @@ 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)
sbomURI := strings.TrimSpace(res.Value.StringVal)
if v, ok := ss[marker]; ok {
v.SBOMURI = sbomURI
} else {
ss[marker] = &StructuredSignable{SBOMURI: sbomURI}
}
}
if sbomFormatSuffix != "" && strings.HasSuffix(res.Name, sbomFormatSuffix) {
if res.Value.StringVal == "" {
logger.Debugf("error getting string value for %s", res.Name)
}
marker := strings.TrimSuffix(res.Name, sbomFormatSuffix)
sbomFormat := strings.TrimSpace(res.Value.StringVal)
if v, ok := ss[marker]; ok {
v.SBOMFormat = sbomFormat
} else {
ss[marker] = &StructuredSignable{SBOMFormat: sbomFormat}
}
}

}
return ss
Expand Down Expand Up @@ -434,3 +461,120 @@ 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.SBOMFormat, obj.URI, obj.Digest, tektonObject))
}
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

ss := extractTargetFromResults(tektonObject, "IMAGE_URL", "IMAGE_DIGEST", "IMAGE_SBOM_URL", "IMAGE_SBOM_FORMAT", logger)
for _, s := range ss {
if s == nil || s.Digest == "" || s.URI == "" || s.SBOMURI == "" || s.SBOMFormat == "" {
continue
}
if _, err := name.NewDigest(s.SBOMURI); err != nil {
logger.Errorf("error getting digest for SBOM image %s: %v", s.SBOMURI, err)
continue
}
objs = append(objs, s)
}

var images []name.Digest
var sboms []string
var sbomsFormat string
// look for a comma separated list of images and their SBOMs
for _, key := range tektonObject.GetResults() {
switch key.Name {
case "IMAGES":
for _, img := range strings.FieldsFunc(key.Value.StringVal, split) {
trimmed := strings.TrimSpace(img)
if trimmed == "" {
continue
}

dgst, err := name.NewDigest(trimmed)
if err != nil {
logger.Errorf("error getting digest for img %s: %v", trimmed, err)
continue
}
images = append(images, dgst)
}
case "SBOMS":
for _, img := range strings.FieldsFunc(key.Value.StringVal, split) {
trimmed := strings.TrimSpace(img)
if trimmed == "" {
continue
}
if _, err := name.NewDigest(trimmed); err != nil {
logger.Errorf("error getting digest for SBOM image %s: %v", trimmed, err)
continue
}
sboms = append(sboms, trimmed)
}
case "SBOMS_FORMAT":
f := strings.TrimSpace(key.Value.StringVal)
if f != "" {
sbomsFormat = f
}
}
}

if len(images) != len(sboms) {
logger.Warnf("IMAGES and SBOMS do not contain the same amount of entries")
return objs
}

if len(sboms) > 0 && sbomsFormat == "" {
logger.Warnf("SBOMS_FORMAT not specified")
return objs
}

for i, sbom := range sboms {
img := images[i]
objs = append(objs, &StructuredSignable{
URI: img.Name(),
Digest: img.Identifier(),
SBOMURI: sbom,
SBOMFormat: sbomsFormat,
})
}
return objs
}
3 changes: 2 additions & 1 deletion pkg/chains/formats/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ import (
"fmt"

"github.com/tektoncd/chains/pkg/config"
"k8s.io/client-go/kubernetes"
)

// Payloader is an interface to generate a chains Payload from a TaskRun
type Payloader interface {
CreatePayload(ctx context.Context, obj interface{}) (interface{}, error)
CreatePayload(ctx context.Context, kc kubernetes.Interface, obj interface{}) (interface{}, error)
Type() config.PayloadType
Wrap() bool
}
Expand Down
107 changes: 107 additions & 0 deletions pkg/chains/formats/sbom/sbom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
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"
"go.uber.org/zap"
"k8s.io/client-go/kubernetes"
)

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

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

data, err := getData(ctx, kc, sbom)
if err != nil {
return nil, err
}

att := intoto.Statement{
StatementHeader: intoto.StatementHeader{
Type: intoto.StatementInTotoV01,
PredicateType: sbom.GetSBOMFormat(),
Subject: subject,
},
Predicate: data,
}
return att, nil
}

func getData(ctx context.Context, kc kubernetes.Interface, sbom *objects.SBOMObject) (json.RawMessage, error) {
opt, err := sbom.OCIRemoteOption(ctx, kc)
if err != nil {
return nil, err
}

uri := sbom.GetSBOMURL()
ref, err := name.ParseReference(uri)
if err != nil {
return nil, err
}

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

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

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

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

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

var data json.RawMessage
if err := json.Unmarshal(blob.Bytes(), &data); err != nil {
return nil, err
}
return data, nil
}

func toDigestSet(digest string) slsa.DigestSet {
algo, value, found := strings.Cut(digest, ":")
if !found {
value = algo
algo = "sha256"
}
return slsa.DigestSet{algo: value}
}
3 changes: 2 additions & 1 deletion pkg/chains/formats/simple/simple.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/sigstore/sigstore/pkg/signature/payload"
"github.com/tektoncd/chains/pkg/chains/formats"
"github.com/tektoncd/chains/pkg/config"
"k8s.io/client-go/kubernetes"

"github.com/google/go-containerregistry/pkg/name"
)
Expand All @@ -39,7 +40,7 @@ type SimpleSigning struct{}
type SimpleContainerImage payload.SimpleContainerImage

// CreatePayload implements the Payloader interface.
func (i *SimpleSigning) CreatePayload(ctx context.Context, obj interface{}) (interface{}, error) {
func (i *SimpleSigning) CreatePayload(ctx context.Context, _ kubernetes.Interface, obj interface{}) (interface{}, error) {
switch v := obj.(type) {
case name.Digest:
format := NewSimpleStruct(v)
Expand Down
4 changes: 2 additions & 2 deletions pkg/chains/formats/simple/simple_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func TestSimpleSigning_CreatePayload(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &SimpleSigning{}
got, err := i.CreatePayload(context.Background(), tt.obj)
got, err := i.CreatePayload(context.Background(), nil, tt.obj)
if (err != nil) != tt.wantErr {
t.Errorf("SimpleSigning.CreatePayload() error = %v, wantErr %v", err, tt.wantErr)
return
Expand All @@ -82,7 +82,7 @@ func TestImageName(t *testing.T) {
obj := makeDigest(t, img)

i := &SimpleSigning{}
format, err := i.CreatePayload(context.Background(), obj)
format, err := i.CreatePayload(context.Background(), nil, obj)
if err != nil {
t.Fatal(err)
}
Expand Down
8 changes: 7 additions & 1 deletion pkg/chains/formats/slsa/v1/intotoite6.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import (
"fmt"

"github.com/tektoncd/chains/pkg/chains/formats"
"github.com/tektoncd/chains/pkg/chains/formats/sbom"
"github.com/tektoncd/chains/pkg/chains/formats/slsa/v1/pipelinerun"
"github.com/tektoncd/chains/pkg/chains/formats/slsa/v1/taskrun"
"github.com/tektoncd/chains/pkg/chains/objects"
"github.com/tektoncd/chains/pkg/config"
"k8s.io/client-go/kubernetes"
"knative.dev/pkg/logging"
)

Expand Down Expand Up @@ -52,13 +54,17 @@ func (i *InTotoIte6) Wrap() bool {
return true
}

func (i *InTotoIte6) CreatePayload(ctx context.Context, obj interface{}) (interface{}, error) {
func (i *InTotoIte6) CreatePayload(ctx context.Context, kc kubernetes.Interface, obj interface{}) (interface{}, error) {
logger := logging.FromContext(ctx)
switch v := obj.(type) {
case *objects.TaskRunObject:
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(ctx, kc, i.builderID, v, logger)
default:
return nil, fmt.Errorf("intoto does not support type: %s", v)
}
Expand Down
Loading

0 comments on commit c8ab778

Please sign in to comment.