Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion pkg/build/sbom_step.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"maps"
"os"
"path/filepath"
Expand All @@ -13,6 +14,8 @@ import (
"time"

cdx "github.com/CycloneDX/cyclonedx-go"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/tarball"
packageurl "github.com/package-url/packageurl-go"
"github.com/samber/lo"
"github.com/sigstore/sigstore/pkg/signature"
Expand All @@ -32,7 +35,9 @@ import (
"github.com/werf/werf/v3/pkg/sbom/managedinput"
osPm "github.com/werf/werf/v3/pkg/sbom/packages/os_pm"
"github.com/werf/werf/v3/pkg/sbom/scanner"
"github.com/werf/werf/v3/pkg/sbom/shippedscope"
"github.com/werf/werf/v3/pkg/storage"
"github.com/werf/werf/v3/pkg/tmp_manager"
"github.com/werf/werf/v3/pkg/werf"
"github.com/werf/werf/v3/pkg/werf/global_warnings"
)
Expand Down Expand Up @@ -202,6 +207,8 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string,

cyclonedxutil.Canonicalize(ctx, resultBOM)

recordShippedFiles(ctx, resultBOM, step.containerBackend, werfImgName, stageDesc.Info.Name)

resultJSON, err := cyclonedxutil.ToJSON(resultBOM)
if err != nil {
return fmt.Errorf("serialize BOM: %w", err)
Expand All @@ -221,6 +228,87 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string,
})
}

// recordShippedFiles records, on every component an ecosystem installed into the image,
// which files of the image are that component's. An image built from this one can then
// ask of an inherited component whether its files are still there, instead of trusting
// that a component its base declared came along.
//
// Runs last, once the BOM holds the components it will ship with: Canonicalize folds
// duplicate components together and drops an evidence list that comes out empty, which is
// exactly the "its files were looked for and none were found" this writes.
//
// The image is read out of the backend the step pulled it into, not from the registry a
// second time: the read is then as long as a save to local disk, and what can fail it is
// the host — an image that is not there, a disk that is full — rather than the network.
//
// Nothing here fails the build. A BOM without these records is the BOM werf wrote before
// it kept them, and a reader that finds no record of a component's files keeps the
// component, so the worst an unreadable image costs is the SBOM of the image built from
// this one being as complete as it used to be.
func recordShippedFiles(ctx context.Context, bom *cdx.BOM, backend imageSaver, werfImgName, imageName string) {
if !shippedscope.HasFileBackedComponents(bom) {
return
}

img, cleanup, err := savedImage(ctx, backend, imageName)
if err != nil {
logboek.Context(ctx).Warn().LogF("WARNING: image %s: unable to read the built image %s; the SBOM records none of the files its components consist of: %s\n", werfImgName, imageName, err)

return
}
defer cleanup()

filesets, err := shippedscope.CollectFilesets(ctx, img)
if err != nil {
logboek.Context(ctx).Warn().LogF("WARNING: image %s: unable to read which files the components of the built image %s consist of; the SBOM records none of them: %s\n", werfImgName, imageName, err)

return
}

recorded := shippedscope.RecordOccurrences(bom, filesets.Filesets)
logboek.Context(ctx).Debug().LogF("Collected %d component filesets of the image, recorded onto %d components\n", len(filesets.Filesets), recorded)
}

// savedImage reads the image out of the backend as a docker-save tarball. The tarball is
// read more than once, once for its manifest and once per layer, which a stream cannot
// give, so it goes to a file first, the same way MutateAndPushImage does. The returned
// cleanup removes that file.
func savedImage(ctx context.Context, backend imageSaver, imageName string) (v1.Image, func(), error) {
stream, err := backend.SaveImageToStream(ctx, imageName)
if err != nil {
return nil, nil, fmt.Errorf("save image: %w", err)
}
defer stream.Close()

file, err := tmp_manager.TempFile("werf-sbom-shipped-files-*.tar")
if err != nil {
return nil, nil, fmt.Errorf("create temp file: %w", err)
}
cleanup := func() { _ = os.Remove(file.Name()) }

if _, err := io.Copy(file, stream); err != nil {
_ = file.Close()
cleanup()

return nil, nil, fmt.Errorf("write saved image to %s: %w", file.Name(), err)
}

if err := file.Close(); err != nil {
cleanup()

return nil, nil, fmt.Errorf("close %s: %w", file.Name(), err)
}

img, err := tarball.ImageFromPath(file.Name(), nil)
if err != nil {
cleanup()

return nil, nil, fmt.Errorf("read saved image %s: %w", file.Name(), err)
}

return img, cleanup, nil
}

// restoreImageMetadata sets the BOM's top-level component to the scanned image while
// keeping any syft-provided metadata (tools, timestamp). A directory source reports the
// temporary scan directory as its component, so it must be replaced; the dependency edges
Expand Down Expand Up @@ -460,7 +548,7 @@ func (step *sbomStep) recordDeclaredPackages(ctx context.Context, bom *cdx.BOM,
return nil
}

const sbomArtifactFormatVersion = "10"
const sbomArtifactFormatVersion = "11"

// calculateStableChecksum computes the SBOM artifact cache checksum. Together with the
// parent stage digest it forms the cache key: a previously attached SBOM is reused only
Expand Down
12 changes: 12 additions & 0 deletions pkg/build/sbom_step_saved_image.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package build

import (
"context"
"io"
)

// Kept out of sbom_step.go: the mockgen directive there generates a mock for every
// interface in its source file, and a test double for this one is a stub, not a mock.
type imageSaver interface {
SaveImageToStream(ctx context.Context, imageName string) (io.ReadCloser, error)
}
245 changes: 245 additions & 0 deletions pkg/build/sbom_step_shipped_files_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package build

import (
"archive/tar"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"path/filepath"
"strings"

cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/static"
"github.com/google/go-containerregistry/pkg/v1/tarball"
"github.com/google/go-containerregistry/pkg/v1/types"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/samber/lo"

"github.com/werf/logboek"
"github.com/werf/werf/v3/pkg/werf"
)

type savedImageBackend struct {
tarball []byte
err error
saved []string
}

var _ imageSaver = (*savedImageBackend)(nil)

func (b *savedImageBackend) SaveImageToStream(_ context.Context, imageName string) (io.ReadCloser, error) {
b.saved = append(b.saved, imageName)
if b.err != nil {
return nil, b.err
}

return io.NopCloser(bytes.NewReader(b.tarball)), nil
}

var _ = Describe("recordShippedFiles", func() {
tarOf := func(files, symlinks map[string]string) []byte {
var buf bytes.Buffer
writer := tar.NewWriter(&buf)
for _, path := range lo.Keys(files) {
body := files[path]
Expect(writer.WriteHeader(&tar.Header{
Name: strings.TrimPrefix(path, "/"),
Typeflag: tar.TypeReg,
Size: int64(len(body)),
Mode: 0o644,
})).To(Succeed())
_, err := io.WriteString(writer, body)
Expect(err).To(Succeed())
}
for _, path := range lo.Keys(symlinks) {
Expect(writer.WriteHeader(&tar.Header{
Name: strings.TrimPrefix(path, "/"),
Typeflag: tar.TypeSymlink,
Linkname: symlinks[path],
Mode: 0o777,
})).To(Succeed())
}
Expect(writer.Close()).To(Succeed())

return buf.Bytes()
}

// Buildah saves layers uncompressed and Docker may save them either way; tarball.Write
// copies what Compressed() gives, so the layer type decides which the saved tarball holds.
gzippedLayer := func(contents []byte) v1.Layer {
layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(contents)), nil
})
Expect(err).To(Succeed())

return layer
}
uncompressedLayer := func(contents []byte) v1.Layer {
return static.NewLayer(contents, types.DockerUncompressedLayer)
}

backendSaving := func(layerOf func([]byte) v1.Layer, files, symlinks map[string]string) *savedImageBackend {
img, err := mutate.AppendLayers(empty.Image, layerOf(tarOf(files, symlinks)))
Expect(err).To(Succeed())

tag, err := name.NewTag("werf-stages:app")
Expect(err).To(Succeed())

var saved bytes.Buffer
Expect(tarball.Write(tag, img, &saved)).To(Succeed())

return &savedImageBackend{tarball: saved.Bytes()}
}

backendWith := func(files, symlinks map[string]string) *savedImageBackend {
return backendSaving(gzippedLayer, files, symlinks)
}

digestOf := func(body string) string {
sum := sha256.Sum256([]byte(body))

return "sha256:" + hex.EncodeToString(sum[:])
}

logged := func(ctx SpecContext, record func(ctx context.Context)) string {
var out bytes.Buffer
logger := logboek.NewLogger(&out, &out)
logger.Streams().DisableLineWrapping()
record(logboek.NewContext(ctx, logger))

return out.String()
}

savedTarballs := func() []string {
matches, err := filepath.Glob(filepath.Join(werf.GetTmpDir(), "werf-sbom-shipped-files-*.tar"))
Expect(err).To(Succeed())

return matches
}

BeforeEach(func() {
Expect(werf.Init(GinkgoT().TempDir(), "")).To(Succeed())
})

DescribeTable("records the files of the image's own packages onto its BOM", func(ctx SpecContext, layerOf func([]byte) v1.Layer) {
backend := backendSaving(layerOf, map[string]string{
"/var/lib/pm/index.json": `{}`,
"/var/lib/pm/files.json": `{"zlib":{"version":"1.3.1","files":{"/usr/lib/libz.so.1.3.1":"sha256:recorded-at-unpack"},"symlinks":{"/usr/lib/libz.so.1":"libz.so.1.3.1"}}}`,
"/usr/lib/libz.so.1.3.1": "zlib body",
"/usr/lib/python3/requests-2.32.3.dist-info/RECORD": "requests/__init__.py,sha256=x,12\n",
"/usr/lib/python3/requests/__init__.py": "requests body",
}, map[string]string{
"/usr/lib/libz.so.1": "libz.so.1.3.1",
})

bom := &cdx.BOM{Components: &[]cdx.Component{
{BOMRef: "zlib", Name: "zlib", PackageURL: "pkg:generic/zlib@1.3.1?containerfactoryversion=1"},
{BOMRef: "requests", Name: "requests", PackageURL: "pkg:pypi/requests@2.32.3"},
{BOMRef: "libc6", Name: "libc6", PackageURL: "pkg:deb/debian/libc6@2.36"},
}}

recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app")

Expect(backend.saved).To(Equal([]string{"werf-stages:app"}))
components := *bom.Components
Expect(lo.FromPtr(components[0].Evidence.Occurrences)).To(Equal([]cdx.EvidenceOccurrence{
{Location: "/usr/lib/libz.so.1", AdditionalContext: "symlink"},
{Location: "/usr/lib/libz.so.1.3.1", AdditionalContext: digestOf("zlib body")},
}))
Expect(lo.FromPtr(components[1].Evidence.Occurrences)).To(Equal([]cdx.EvidenceOccurrence{
{Location: "/usr/lib/python3/requests/__init__.py", AdditionalContext: digestOf("requests body")},
}))
Expect(components[2].Evidence).To(BeNil())
Expect(savedTarballs()).To(BeEmpty())
},
Entry("saved with gzipped layers", gzippedLayer),
Entry("saved with uncompressed layers, as Buildah saves them", uncompressedLayer),
)

It("leaves os-pm packages unmarked when the image was installed by a pm that wrote no file list", func(ctx SpecContext) {
backend := backendWith(map[string]string{
"/var/lib/pm/index.json": `{}`,
"/usr/lib/libz.so.1.3.1": "zlib body",
}, nil)

bom := &cdx.BOM{Components: &[]cdx.Component{
{BOMRef: "zlib", Name: "zlib", PackageURL: "pkg:generic/zlib@1.3.1"},
}}

recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app")

Expect((*bom.Components)[0].Evidence).To(BeNil())
})

It("warns and leaves the BOM as it was when the backend cannot save the image", func(ctx SpecContext) {
backend := &savedImageBackend{err: errors.New("no such image")}
bom := &cdx.BOM{Components: &[]cdx.Component{
{BOMRef: "zlib", Name: "zlib", PackageURL: "pkg:generic/zlib@1.3.1"},
}}

out := logged(ctx, func(ctx context.Context) {
recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app")
})

Expect(out).To(ContainSubstring("image app: unable to read the built image werf-stages:app"))
Expect(out).To(ContainSubstring("no such image"))
Expect((*bom.Components)[0].Evidence).To(BeNil())
})

It("warns and leaves the BOM as it was when the saved image is not an image", func(ctx SpecContext) {
backend := &savedImageBackend{tarball: []byte("not a tarball")}
bom := &cdx.BOM{Components: &[]cdx.Component{
{BOMRef: "zlib", Name: "zlib", PackageURL: "pkg:generic/zlib@1.3.1"},
}}

out := logged(ctx, func(ctx context.Context) {
recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app")
})

Expect(out).To(ContainSubstring("image app: unable to read the built image werf-stages:app"))
Expect((*bom.Components)[0].Evidence).To(BeNil())
Expect(savedTarballs()).To(BeEmpty())
})

It("warns and leaves the BOM as it was when the image's file lists cannot be read", func(ctx SpecContext) {
backend := backendWith(map[string]string{
"/var/lib/pm/index.json": `{}`,
"/var/lib/pm/files.json": `not json at all`,
}, nil)

bom := &cdx.BOM{Components: &[]cdx.Component{
{BOMRef: "zlib", Name: "zlib", PackageURL: "pkg:generic/zlib@1.3.1"},
}}

out := logged(ctx, func(ctx context.Context) {
recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app")
})

Expect(out).To(ContainSubstring("image app: unable to read which files the components of the built image werf-stages:app consist of"))
Expect((*bom.Components)[0].Evidence).To(BeNil())
Expect(savedTarballs()).To(BeEmpty())
})

It("warns nothing and does not save the image when no component could be given files", func(ctx SpecContext) {
backend := &savedImageBackend{err: errors.New("must not be called")}
bom := &cdx.BOM{Components: &[]cdx.Component{
{BOMRef: "yaml", Name: "gopkg.in/yaml.v3", PackageURL: "pkg:golang/gopkg.in/yaml.v3@3.0.1"},
{BOMRef: "libc6", Name: "libc6", PackageURL: "pkg:deb/debian/libc6@2.36"},
}}

out := logged(ctx, func(ctx context.Context) {
recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app")
})

Expect(out).To(BeEmpty())
Expect(backend.saved).To(BeEmpty())
})
})
Loading