From 2486fa0a9c283217d989772e78bf818debdd2a36 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Wed, 21 Oct 2026 23:12:39 +0300 Subject: [PATCH 1/7] feat(sbom): retain Go and Rust dependencies by shipped binary content Record digest-bound binary evidence from verified production layers and preserve it through component merges and imports. Keep dependencies when provenance, classification, inherited evidence or hardlink reconstruction is incomplete. Signed-off-by: Radmir Khurum --- pkg/sbom/shippedscope/binary_occurrences.go | 210 +++++++ .../shippedscope/binary_occurrences_test.go | 177 ++++++ pkg/sbom/shippedscope/file_index.go | 8 + pkg/sbom/shippedscope/file_index_test.go | 18 + pkg/sbom/shippedscope/helpers_test.go | 13 + pkg/sbom/shippedscope/mark.go | 18 +- pkg/sbom/shippedscope/occurrences.go | 17 +- pkg/sbom/shippedscope/produced.go | 197 +++++++ .../shippedscope/produced_overlay_test.go | 85 +++ pkg/sbom/shippedscope/produced_test.go | 538 ++++++++++++++++++ 10 files changed, 1273 insertions(+), 8 deletions(-) create mode 100644 pkg/sbom/shippedscope/binary_occurrences.go create mode 100644 pkg/sbom/shippedscope/binary_occurrences_test.go create mode 100644 pkg/sbom/shippedscope/produced.go create mode 100644 pkg/sbom/shippedscope/produced_overlay_test.go create mode 100644 pkg/sbom/shippedscope/produced_test.go diff --git a/pkg/sbom/shippedscope/binary_occurrences.go b/pkg/sbom/shippedscope/binary_occurrences.go new file mode 100644 index 0000000000..494725e4cf --- /dev/null +++ b/pkg/sbom/shippedscope/binary_occurrences.go @@ -0,0 +1,210 @@ +package shippedscope + +import ( + "cmp" + "context" + "path" + "slices" + "strings" + + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/samber/lo" +) + +const PropertyBinaries = "werf:binaries" + +const ( + binaryEvidencePrefix = "werf:binary:v1:" + propertyBinariesNone = "none" + propertyBinariesUnknown = "unknown" +) + +type binaryOccurrence struct { + Path string + Digest string + Unknown bool +} + +// PrepareBinaryOccurrences records only the fresh scan's local production evidence. +// Inherited origins must be normalized separately before equal components are merged. +func PrepareBinaryOccurrences(ctx context.Context, bom *cdx.BOM, produced *ProducedFiles) { + walkComponents(bom, func(comp *cdx.Component) { + key, ok := keyOfComponent(comp) + if !ok || !isBinaryBacked(key.ecosystem) { + return + } + removeProperty(comp, PropertyBinaries) + if comp.Evidence != nil { + kept := lo.Filter(lo.FromPtr(comp.Evidence.Occurrences), func(occ cdx.EvidenceOccurrence, _ int) bool { + return !strings.HasPrefix(occ.AdditionalContext, "werf:binary:") + }) + comp.Evidence.Occurrences = &kept + } + setProperty(comp, PropertyBinaries, propertyBinariesNone) + }) + RecordProducedBinaryOccurrences(ctx, bom, produced) +} + +// NormalizeInheritedBinaryOccurrences preserves an unknown origin before a merge can +// hide its missing evidence behind another origin's positive or checked-empty record. +func NormalizeInheritedBinaryOccurrences(_ context.Context, bom *cdx.BOM) { + walkComponents(bom, func(comp *cdx.Component) { + key, ok := keyOfComponent(comp) + if !ok || !isBinaryBacked(key.ecosystem) { + return + } + _, incomplete := binaryOccurrences(comp) + if incomplete { + addBinaryProperty(comp, propertyBinariesUnknown) + } + }) +} + +// RecordProducedBinaryOccurrences adds local proof without erasing an inherited +// unknown origin. An unreadable binary binds uncertainty to its own content only. +func RecordProducedBinaryOccurrences(_ context.Context, bom *cdx.BOM, produced *ProducedFiles) { + walkComponents(bom, func(comp *cdx.Component) { + key, ok := keyOfComponent(comp) + if !ok || !isBinaryBacked(key.ecosystem) { + return + } + removeProperty(comp, PropertyShipped) + if !produced.Known { + addBinaryProperty(comp, propertyBinariesUnknown) + return + } + for _, file := range produced.Files { + if file.Classification.TestBinary { + continue + } + verdict := file.Classification.Go + named := slices.Contains(file.Classification.Modules, key.name) + if key.ecosystem == EcosystemRust { + verdict = file.Classification.Rust + named = slices.Contains(file.Classification.Crates, Crate{Name: key.name, Version: key.version}) + } + switch verdict { + case VerdictNone: + continue + case VerdictNames: + if !named { + continue + } + case VerdictUnknown: + default: + addBinaryProperty(comp, propertyBinariesUnknown) + continue + } + context := binaryEvidencePrefix + file.Digest + if verdict == VerdictUnknown { + context = binaryEvidencePrefix + "unknown:" + file.Digest + } + occurrence := cdx.EvidenceOccurrence{Location: file.Path, AdditionalContext: context} + if _, ok := parseBinaryOccurrence(occurrence); !ok { + addBinaryProperty(comp, propertyBinariesUnknown) + continue + } + if comp.Evidence == nil { + comp.Evidence = &cdx.Evidence{} + } + occurrences := lo.FromPtr(comp.Evidence.Occurrences) + if !slices.Contains(occurrences, occurrence) { + occurrences = append(occurrences, occurrence) + } + slices.SortFunc(occurrences, func(a, b cdx.EvidenceOccurrence) int { + return cmp.Or(cmp.Compare(a.Location, b.Location), cmp.Compare(a.AdditionalContext, b.AdditionalContext)) + }) + comp.Evidence.Occurrences = &occurrences + properties := lo.Filter(lo.FromPtr(comp.Properties), func(prop cdx.Property, _ int) bool { + return prop.Name != PropertyBinaries || prop.Value != propertyBinariesNone + }) + comp.Properties = &properties + } + }) +} + +func HasBinaryBackedComponents(_ context.Context, bom *cdx.BOM) bool { + found := false + walkComponents(bom, func(comp *cdx.Component) { + key, ok := keyOfComponent(comp) + found = found || (ok && isBinaryBacked(key.ecosystem)) + }) + return found +} + +func addBinaryProperty(comp *cdx.Component, value string) { + property := cdx.Property{Name: PropertyBinaries, Value: value} + properties := lo.FromPtr(comp.Properties) + if !slices.Contains(properties, property) { + comp.Properties = lo.ToPtr(append(properties, property)) + } +} + +func parseBinaryOccurrence(occurrence cdx.EvidenceOccurrence) (binaryOccurrence, bool) { + if !strings.HasPrefix(occurrence.AdditionalContext, binaryEvidencePrefix) || !path.IsAbs(occurrence.Location) || path.Clean(occurrence.Location) != occurrence.Location { + return binaryOccurrence{}, false + } + context := strings.TrimPrefix(occurrence.AdditionalContext, binaryEvidencePrefix) + unknown := strings.HasPrefix(context, "unknown:") + context = strings.TrimPrefix(context, "unknown:") + if !strings.HasPrefix(context, digestPrefix) { + return binaryOccurrence{}, false + } + hex := strings.TrimPrefix(context, digestPrefix) + if len(hex) != 64 || strings.IndexFunc(hex, func(r rune) bool { return !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') }) >= 0 { + return binaryOccurrence{}, false + } + return binaryOccurrence{Path: occurrence.Location, Digest: context, Unknown: unknown}, true +} + +func binaryOccurrences(comp *cdx.Component) ([]binaryOccurrence, bool) { + var occurrences []binaryOccurrence + complete, incomplete := false, false + for _, property := range lo.FromPtr(comp.Properties) { + if property.Name != PropertyBinaries { + continue + } + if property.Value == propertyBinariesNone { + complete = true + } else { + incomplete = true + } + } + if comp.Evidence != nil { + for _, raw := range lo.FromPtr(comp.Evidence.Occurrences) { + if !strings.HasPrefix(raw.AdditionalContext, "werf:binary:") { + continue + } + occurrence, ok := parseBinaryOccurrence(raw) + if !ok { + incomplete = true + continue + } + occurrences = append(occurrences, occurrence) + complete = true + } + } + return occurrences, incomplete || !complete +} + +func binaryDecision(comp *cdx.Component, indexIncomplete bool, byDigest map[string][]string) Decision { + occurrences, incomplete := binaryOccurrences(comp) + unknownPresent := false + for _, occurrence := range occurrences { + if len(byDigest[occurrence.Digest]) == 0 { + continue + } + if occurrence.Unknown { + unknownPresent = true + continue + } + return Decision{Shipped: true, Reason: ReasonBinaryInherited, Matched: byDigest[occurrence.Digest]} + } + if unknownPresent { + return Decision{Shipped: true, Reason: ReasonFailOpen} + } + if incomplete || indexIncomplete { + return Decision{Shipped: true, Reason: ReasonNoData} + } + return Decision{Reason: ReasonNotLinked} +} diff --git a/pkg/sbom/shippedscope/binary_occurrences_test.go b/pkg/sbom/shippedscope/binary_occurrences_test.go new file mode 100644 index 0000000000..6c053ef72c --- /dev/null +++ b/pkg/sbom/shippedscope/binary_occurrences_test.go @@ -0,0 +1,177 @@ +package shippedscope + +import ( + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/samber/lo" + + "github.com/werf/werf/v3/pkg/sbom/cyclonedxutil" +) + +var _ = ginkgo.Describe("Persisted binary evidence", func() { + var donor *cdx.BOM + var produced *ProducedFiles + var fileIndex *FileIndex + + ginkgo.BeforeEach(func() { + donor = cyclonedxutil.NewBOM() + donor.Components = &[]cdx.Component{ + componentOf("pkg:golang/example.com/one@v1.0.0"), + componentOf("pkg:golang/example.com/two@v1.0.0"), + componentOf("pkg:golang/example.com/test-only@v1.0.0"), + } + produced = &ProducedFiles{Known: true, Files: []ProducedFile{ + {Path: "/dist/one", Digest: digestOf("one"), Classification: BinaryClassification{Go: VerdictNames, Modules: []string{"example.com/one"}, Rust: VerdictNone}}, + {Path: "/dist/two", Digest: digestOf("two"), Classification: BinaryClassification{Go: VerdictNames, Modules: []string{"example.com/two"}, Rust: VerdictNone}}, + }} + fileIndex = indexOf(map[string]File{"/renamed/one": {Digest: digestOf("one"), Size: 3}}) + }) + + ginkgo.It("records actual binary paths before merging and marks only unlinked modules", func(ctx ginkgo.SpecContext) { + PrepareBinaryOccurrences(ctx, donor, produced) + gomega.Expect((*donor.Components)[0].Evidence).NotTo(gomega.BeNil()) + gomega.Expect(lo.FromPtr((*donor.Components)[0].Evidence.Occurrences)).To(gomega.ContainElement(cdx.EvidenceOccurrence{ + Location: "/dist/one", AdditionalContext: "werf:binary:v1:" + digestOf("one"), + })) + gomega.Expect(MarkNotShipped(ctx, donor, filesetsOf(fileIndex))).To(gomega.Equal(2)) + gomega.Expect(propertyValue(&(*donor.Components)[0], PropertyShipped)).To(gomega.BeEmpty()) + gomega.Expect(propertyValue(&(*donor.Components)[1], PropertyShipped)).To(gomega.Equal("false")) + gomega.Expect(propertyValue(&(*donor.Components)[2], PropertyShipped)).To(gomega.Equal("false")) + }) + + ginkgo.DescribeTable("survives serialization, merge, selective import and another generation", func(ctx ginkgo.SpecContext, donorFirst bool) { + PrepareBinaryOccurrences(ctx, donor, produced) + data, err := cyclonedxutil.ToJSON(donor) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + read, err := cyclonedxutil.BuildCycloneDX16BOMFromJSON(data) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + NormalizeInheritedBinaryOccurrences(ctx, read) + other, err := cyclonedxutil.CloneBOM(read) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + PrepareBinaryOccurrences(ctx, other, &ProducedFiles{Known: true}) + if !donorFirst { + read, other = other, read + } + merged, err := cyclonedxutil.MergeBOMs(ctx, other, cyclonedxutil.MergeOpts{BaseBOM: read}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + RecordProducedBinaryOccurrences(ctx, merged, &ProducedFiles{Known: true}) + gomega.Expect(MarkNotShipped(ctx, merged, filesetsOf(fileIndex))).To(gomega.Equal(2)) + data, err = cyclonedxutil.ToJSON(merged) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + grandchild, err := cyclonedxutil.BuildCycloneDX16BOMFromJSON(data) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + NormalizeInheritedBinaryOccurrences(ctx, grandchild) + RecordProducedBinaryOccurrences(ctx, grandchild, &ProducedFiles{Known: true}) + gomega.Expect(MarkNotShipped(ctx, grandchild, filesetsOf(fileIndex))).To(gomega.Equal(2)) + gomega.Expect(MarkNotShipped(ctx, grandchild, filesetsOf(emptyIndex()))).To(gomega.Equal(3)) + }, ginkgo.Entry("donor first", true), ginkgo.Entry("donor last", false)) + + ginkgo.DescribeTable("preserves unknown origins even beside positive and checked-empty evidence", func(ctx ginkgo.SpecContext, unknownFirst bool) { + old, err := cyclonedxutil.CloneBOM(donor) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + NormalizeInheritedBinaryOccurrences(ctx, old) + PrepareBinaryOccurrences(ctx, donor, produced) + if unknownFirst { + donor, old = old, donor + } + merged, err := cyclonedxutil.MergeBOMs(ctx, donor, cyclonedxutil.MergeOpts{BaseBOM: old}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + RecordProducedBinaryOccurrences(ctx, merged, &ProducedFiles{Known: true}) + gomega.Expect(MarkNotShipped(ctx, merged, filesetsOf(fileIndex))).To(gomega.BeZero()) + data, err := cyclonedxutil.ToJSON(merged) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + grandchild, err := cyclonedxutil.BuildCycloneDX16BOMFromJSON(data) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + NormalizeInheritedBinaryOccurrences(ctx, grandchild) + gomega.Expect(MarkNotShipped(ctx, grandchild, filesetsOf(emptyIndex()))).To(gomega.BeZero()) + }, ginkgo.Entry("unknown first", true), ginkgo.Entry("unknown last", false)) + + ginkgo.It("does not promote digest-bound unknown to permanent unknown through normalization", func(ctx ginkgo.SpecContext) { + produced.Files = []ProducedFile{{Path: "/dist/opaque", Digest: digestOf("opaque"), Classification: BinaryClassification{Go: VerdictUnknown, Rust: VerdictNone}}} + PrepareBinaryOccurrences(ctx, donor, produced) + data, err := cyclonedxutil.ToJSON(donor) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + read, err := cyclonedxutil.BuildCycloneDX16BOMFromJSON(data) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + NormalizeInheritedBinaryOccurrences(ctx, read) + gomega.Expect(MarkNotShipped(ctx, read, filesetsOf(indexOf(map[string]File{"/opaque": {Digest: digestOf("opaque"), Size: 6}})))).To(gomega.BeZero()) + gomega.Expect(MarkNotShipped(ctx, read, filesetsOf(emptyIndex()))).To(gomega.Equal(3)) + }) + + ginkgo.DescribeTable("does not trust ordinary or malformed occurrences as binary proof", func(ctx ginkgo.SpecContext, context string) { + donor.Components = &[]cdx.Component{componentOf("pkg:golang/example.com/one@v1.0.0", cdx.EvidenceOccurrence{Location: "/app/go.mod", AdditionalContext: context})} + NormalizeInheritedBinaryOccurrences(ctx, donor) + RecordProducedBinaryOccurrences(ctx, donor, &ProducedFiles{Known: true}) + gomega.Expect(propertyValue(&(*donor.Components)[0], PropertyBinaries)).To(gomega.Equal("unknown")) + gomega.Expect(MarkNotShipped(ctx, donor, filesetsOf(fileIndex))).To(gomega.BeZero()) + gomega.Expect(MarkNotShipped(ctx, donor, filesetsOf(emptyIndex()))).To(gomega.BeZero()) + }, + ginkgo.Entry("manifest hash", digestOf("one")), + ginkgo.Entry("source without hash", ""), + ginkgo.Entry("symlink", "symlink"), + ginkgo.Entry("truncated binary hash", "werf:binary:v1:sha256:ab"), + ginkgo.Entry("future format", "werf:binary:v2:"+digestOf("one")), + ) + + ginkgo.It("does not let a manifest hash hold a completely checked module", func(ctx ginkgo.SpecContext) { + donor.Components = &[]cdx.Component{componentOf("pkg:golang/example.com/one@v1.0.0", cdx.EvidenceOccurrence{Location: "/app/go.mod", AdditionalContext: digestOf("one")})} + PrepareBinaryOccurrences(ctx, donor, &ProducedFiles{Known: true}) + gomega.Expect(MarkNotShipped(ctx, donor, filesetsOf(fileIndex))).To(gomega.Equal(1)) + }) + + ginkgo.It("keeps unknown when none appears earlier in the property list", func(ctx ginkgo.SpecContext) { + PrepareBinaryOccurrences(ctx, donor, &ProducedFiles{Known: true}) + for i := range *donor.Components { + (*donor.Components)[i].Properties = &[]cdx.Property{{Name: PropertyBinaries, Value: "none"}, {Name: PropertyBinaries, Value: "unknown"}} + } + NormalizeInheritedBinaryOccurrences(ctx, donor) + gomega.Expect(MarkNotShipped(ctx, donor, filesetsOf(emptyIndex()))).To(gomega.BeZero()) + }) + + ginkgo.It("does not let malformed evidence hide behind complete evidence or none", func(ctx ginkgo.SpecContext) { + PrepareBinaryOccurrences(ctx, donor, produced) + for i := range *donor.Components { + comp := &(*donor.Components)[i] + if comp.Evidence == nil { + comp.Evidence = &cdx.Evidence{} + } + comp.Evidence.Occurrences = lo.ToPtr(append(lo.FromPtr(comp.Evidence.Occurrences), cdx.EvidenceOccurrence{Location: "/broken", AdditionalContext: "werf:binary:v1:sha256:bad"})) + } + NormalizeInheritedBinaryOccurrences(ctx, donor) + gomega.Expect(MarkNotShipped(ctx, donor, filesetsOf(emptyIndex()))).To(gomega.BeZero()) + }) + + ginkgo.It("allows a child to compile dependencies of a packages-only donor", func(ctx ginkgo.SpecContext) { + PrepareBinaryOccurrences(ctx, donor, &ProducedFiles{Known: true}) + NormalizeInheritedBinaryOccurrences(ctx, donor) + RecordProducedBinaryOccurrences(ctx, donor, produced) + gomega.Expect(MarkNotShipped(ctx, donor, filesetsOf(fileIndex))).To(gomega.Equal(2)) + gomega.Expect(propertyValue(&(*donor.Components)[0], PropertyShipped)).To(gomega.BeEmpty()) + }) + + ginkgo.It("keeps ecosystems independent and matches Go path but Rust name and version", func(ctx ginkgo.SpecContext) { + donor.Components = &[]cdx.Component{ + componentOf("pkg:golang/example.com/one@v9.0.0"), componentOf("pkg:golang/example.com/missing@v1.0.0"), + componentOf("pkg:cargo/serde@1.0.0"), componentOf("pkg:cargo/serde@2.0.0"), + } + produced.Files[0].Classification.Rust = VerdictNames + produced.Files[0].Classification.Crates = []Crate{{Name: "serde", Version: "1.0.0"}} + PrepareBinaryOccurrences(ctx, donor, produced) + gomega.Expect(MarkNotShipped(ctx, donor, filesetsOf(fileIndex))).To(gomega.Equal(2)) + gomega.Expect(propertyValue(&(*donor.Components)[0], PropertyShipped)).To(gomega.BeEmpty()) + gomega.Expect(propertyValue(&(*donor.Components)[2], PropertyShipped)).To(gomega.BeEmpty()) + produced.Files[0].Classification.Rust = VerdictUnknown + PrepareBinaryOccurrences(ctx, donor, produced) + gomega.Expect(MarkNotShipped(ctx, donor, filesetsOf(fileIndex))).To(gomega.Equal(1)) + gomega.Expect(propertyValue(&(*donor.Components)[1], PropertyShipped)).To(gomega.Equal("false")) + }) + + ginkgo.It("never uses test binaries or incomplete indexing as negative evidence", func(ctx ginkgo.SpecContext) { + produced.Files[0].Classification.TestBinary = true + PrepareBinaryOccurrences(ctx, donor, produced) + gomega.Expect(MarkNotShipped(ctx, donor, filesetsOf(fileIndex))).To(gomega.Equal(3)) + fileIndex.Incomplete = true + gomega.Expect(MarkNotShipped(ctx, donor, filesetsOf(fileIndex))).To(gomega.BeZero()) + }) +}) diff --git a/pkg/sbom/shippedscope/file_index.go b/pkg/sbom/shippedscope/file_index.go index 49182e06b1..4db41a1270 100644 --- a/pkg/sbom/shippedscope/file_index.go +++ b/pkg/sbom/shippedscope/file_index.go @@ -30,6 +30,10 @@ type FileIndex struct { Files map[string]File // Symlinks holds targets exactly as the image records them: possibly relative, possibly dangling. Symlinks map[string]string + // Incomplete also covers hardlinks resolved against the final filesystem rather + // than their owning layer: later target changes may have hidden surviving bytes. + // Such an index cannot establish that a digest is absent. + Incomplete bool // bodies holds the captured content by indexed name; targetBodies holds, by target // path, the content a hardlink's name asked for while the layers were read. @@ -124,6 +128,9 @@ func indexImageFiles(ctx context.Context, img v1.Image, capture func(path string return nil, nil, err } + if len(remaining) > 0 { + index.Incomplete = true + } resolveHardlinks(ctx, index, remaining) captured := index.bodies @@ -353,6 +360,7 @@ func resolveHardlinks(ctx context.Context, index *FileIndex, hardlinks map[strin } if unresolved > 0 { + index.Incomplete = true logboek.Context(ctx).Warn().LogF("WARNING: %d hardlinks could not be resolved to content and are left out of the file index\n", unresolved) } } diff --git a/pkg/sbom/shippedscope/file_index_test.go b/pkg/sbom/shippedscope/file_index_test.go index adc7a48804..3a0fbb1131 100644 --- a/pkg/sbom/shippedscope/file_index_test.go +++ b/pkg/sbom/shippedscope/file_index_test.go @@ -288,6 +288,7 @@ var _ = Describe("IndexImageFiles", func() { Expect(err).NotTo(HaveOccurred()) Expect(index.Files).To(BeEmpty()) + Expect(index.Incomplete).To(BeTrue(), "unresolved hardlinks mark the index incomplete") }) It("gives up on hardlinks that point at each other", func(ctx SpecContext) { @@ -301,6 +302,7 @@ var _ = Describe("IndexImageFiles", func() { Expect(err).NotTo(HaveOccurred()) Expect(index.Files).To(BeEmpty()) + Expect(index.Incomplete).To(BeTrue(), "circular hardlinks mark the index incomplete") }, SpecTimeout(time.Minute)) It("reports an image it cannot read instead of returning a partial index", func(ctx SpecContext) { @@ -335,5 +337,21 @@ var _ = Describe("IndexImageFiles", func() { Expect(index.Files).To(BeEmpty()) Expect(index.Symlinks).To(BeEmpty()) + Expect(index.Incomplete).To(BeFalse()) + }) + + It("marks the index complete when all hardlinks resolve", func(ctx SpecContext) { + img, err := imageOf([]tarEntry{ + regularFile("usr/bin/origin", "shared"), + hardlink("usr/bin/alias", "usr/bin/origin"), + }) + Expect(err).NotTo(HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + Expect(err).NotTo(HaveOccurred()) + + Expect(index.Files).To(HaveKey("/usr/bin/origin")) + Expect(index.Files).To(HaveKey("/usr/bin/alias")) + Expect(index.Incomplete).To(BeFalse()) }) }) diff --git a/pkg/sbom/shippedscope/helpers_test.go b/pkg/sbom/shippedscope/helpers_test.go index f3f52369f6..1cf0d50388 100644 --- a/pkg/sbom/shippedscope/helpers_test.go +++ b/pkg/sbom/shippedscope/helpers_test.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "io" + cdx "github.com/CycloneDX/cyclonedx-go" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/mutate" @@ -108,3 +109,15 @@ func filesetOf(filesets *Filesets, ecosystem Ecosystem, name, version string) *F return nil } + +func componentOf(purl string, occurrences ...cdx.EvidenceOccurrence) cdx.Component { + comp := cdx.Component{BOMRef: purl, PackageURL: purl, Name: purl} + if len(occurrences) > 0 { + comp.Evidence = &cdx.Evidence{Occurrences: &occurrences} + } + return comp +} + +func filesetsOf(index *FileIndex) *Filesets { + return &Filesets{Index: index} +} diff --git a/pkg/sbom/shippedscope/mark.go b/pkg/sbom/shippedscope/mark.go index 0541b38550..01dd48f362 100644 --- a/pkg/sbom/shippedscope/mark.go +++ b/pkg/sbom/shippedscope/mark.go @@ -32,10 +32,6 @@ const propertyShippedFalse = "false" // files this image has no manifest for they are the donor's, which is what lets the rule // find them by content wherever an import put them. // -// Go modules and crates are kept untouched. Each is proven by the binaries this image's -// own build produced, and that set is not established here, so the rule is given none and -// fails open on all of them. -// // Components are passed to the rule together rather than one at a time: whether a file // proves the component that claims it depends on which other components claim the same // bytes. @@ -46,6 +42,8 @@ func MarkNotShipped(ctx context.Context, bom *cdx.BOM, filesets *Filesets) int { var components []Component var judged []*cdx.Component + count := 0 + byDigest := indexByDigest(filesets.Index) walkComponents(bom, func(comp *cdx.Component) { removeProperty(comp, PropertyShipped) @@ -54,6 +52,15 @@ func MarkNotShipped(ctx context.Context, bom *cdx.BOM, filesets *Filesets) int { if !ok { return } + if isBinaryBacked(key.ecosystem) { + decision := binaryDecision(comp, filesets.Index.Incomplete, byDigest) + if !decision.Shipped { + setProperty(comp, PropertyShipped, propertyShippedFalse) + count++ + logboek.Context(ctx).Debug().LogF("not shipped %s %s@%s (%s)\n", key.ecosystem, key.name, key.version, decision.Reason) + } + return + } // The image carries os-pm packages installed by a pm that wrote no file list, so // what the rule would weigh for them is a donor's record of files this image // cannot confirm either way. @@ -71,10 +78,9 @@ func MarkNotShipped(ctx context.Context, bom *cdx.BOM, filesets *Filesets) int { }) if len(components) == 0 { - return 0 + return count } - count := 0 for i, decision := range Match(ctx, components, ProducedBinaries{}, filesets.Index) { if decision.Shipped { continue diff --git a/pkg/sbom/shippedscope/occurrences.go b/pkg/sbom/shippedscope/occurrences.go index 5f1ad5011d..a042f65e92 100644 --- a/pkg/sbom/shippedscope/occurrences.go +++ b/pkg/sbom/shippedscope/occurrences.go @@ -75,6 +75,9 @@ func RecordOccurrences(bom *cdx.BOM, filesets []Fileset) int { // record files for. Reading an image to find out costs a full download of its layers, and // an image whose components are all Go modules, deb packages or anything else no collector // reads has nothing to gain from it. +// +// Go and Rust are binary-backed, not file-backed: they need binary classification, not +// file enumeration. func HasFileBackedComponents(bom *cdx.BOM) bool { if bom == nil { return false @@ -82,9 +85,15 @@ func HasFileBackedComponents(bom *cdx.BOM) bool { found := false walkComponents(bom, func(comp *cdx.Component) { - if _, ok := keyOfComponent(comp); ok { - found = true + key, ok := keyOfComponent(comp) + if !ok { + return } + // Binary-backed ecosystems don't need file collection. + if isBinaryBacked(key.ecosystem) { + return + } + found = true }) return found @@ -134,11 +143,15 @@ func keyOfComponent(comp *cdx.Component) (componentKey, bool) { // pkg:generic is not werf's alone — syft gives it to a runtime it recognizes by its binary // — but taking a fileset needs the same name and version as a package the pm recorded, and // a component agreeing with a pm entry on both is that package. +// +// Go and Rust are binary-backed: they carry no files, only binaries that embed them. var purlEcosystems = map[string]Ecosystem{ packageurl.TypeGeneric: EcosystemOSPM, packageurl.TypePyPi: EcosystemPython, packageurl.TypeNPM: EcosystemJavaScript, luarocksPurlType: EcosystemLua, + packageurl.TypeGolang: EcosystemGo, + packageurl.TypeCargo: EcosystemRust, } const luarocksPurlType = "luarocks" diff --git a/pkg/sbom/shippedscope/produced.go b/pkg/sbom/shippedscope/produced.go new file mode 100644 index 0000000000..e29c422630 --- /dev/null +++ b/pkg/sbom/shippedscope/produced.go @@ -0,0 +1,197 @@ +package shippedscope + +import ( + "archive/tar" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "slices" + + 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/werf/werf/v3/pkg/tmp_manager" +) + +// ProducedLayerRange identifies the layers appended by one eligible stage. Start is +// inclusive and End exclusive; stage boundaries must be verified against image DiffIDs. +type ProducedLayerRange struct { + Start int + End int +} + +type ProducedLayers struct { + Known bool + Ranges []ProducedLayerRange +} + +type ProducedFile struct { + Path string + Digest string + Classification BinaryClassification +} + +// ProducedFiles binds classifications to surviving binary content. Known is false when +// stage attribution or filesystem reconstruction cannot establish a complete set. +type ProducedFiles struct { + Known bool + Files []ProducedFile +} + +// CollectProducedFiles compares each verified stage's filesystem boundaries and records +// the binary content still present in the final image, including relocated hardlinks. +func CollectProducedFiles(ctx context.Context, img v1.Image, index *FileIndex, layers ProducedLayers) (*ProducedFiles, error) { + if !layers.Known || index.Incomplete { + return &ProducedFiles{}, nil + } + imgLayers, err := img.Layers() + if err != nil { + return nil, fmt.Errorf("read image layers: %w", err) + } + for _, r := range layers.Ranges { + if r.Start < 0 || r.Start > r.End || r.End > len(imgLayers) { + return nil, fmt.Errorf("invalid layer range [%d,%d) for image with %d layers", r.Start, r.End, len(imgLayers)) + } + } + + indexes := map[int]*FileIndex{0: {Files: map[string]File{}, Symlinks: map[string]string{}}, len(imgLayers): index} + finalDigests := indexByDigest(index) + candidates := make(map[string]string) + for _, r := range layers.Ranges { + if r.Start == r.End { + continue + } + for _, boundary := range []int{r.Start, r.End} { + if _, ok := indexes[boundary]; ok { + continue + } + prefix, err := mutate.AppendLayers(empty.Image, imgLayers[:boundary]...) + if err != nil { + return nil, fmt.Errorf("read stage filesystem boundary %d: %w", boundary, err) + } + boundaryIndex, err := IndexImageFiles(ctx, prefix) + if err != nil { + return nil, fmt.Errorf("index stage filesystem boundary %d: %w", boundary, err) + } + indexes[boundary] = boundaryIndex + } + before, after := indexes[r.Start], indexes[r.End] + if before.Incomplete || after.Incomplete { + return &ProducedFiles{}, nil + } + for path, file := range after.Files { + if previous, ok := before.Files[path]; ok && previous.Digest == file.Digest { + continue + } + paths := finalDigests[file.Digest] + if len(paths) == 0 { + continue + } + candidates[file.Digest] = slices.Min(paths) + } + } + result := &ProducedFiles{Known: true} + if len(candidates) == 0 { + return result, nil + } + + found := make(map[string]ProducedFile) + for _, layer := range imgLayers { + files, err := classifyProducedLayer(ctx, layer, candidates) + if err != nil { + return nil, err + } + for _, file := range files { + found[file.Digest] = file + } + } + for _, file := range found { + result.Files = append(result.Files, file) + } + slices.SortFunc(result.Files, func(a, b ProducedFile) int { return bytes.Compare([]byte(a.Path), []byte(b.Path)) }) + return result, nil +} + +func classifyProducedLayer(ctx context.Context, layer v1.Layer, candidates map[string]string) ([]ProducedFile, error) { + contents, err := layer.Uncompressed() + if err != nil { + return nil, fmt.Errorf("open produced image layer: %w", err) + } + defer contents.Close() + reader := tar.NewReader(contents) + var files []ProducedFile + for { + if err := ctx.Err(); err != nil { + return nil, err + } + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("read produced image layer: %w", err) + } + if header.Typeflag != tar.TypeReg || header.Size == 0 { + continue + } + prefix := make([]byte, 4) + n, err := io.ReadFull(reader, prefix) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return nil, fmt.Errorf("read produced binary header: %w", err) + } + _, executable, err := formatOf(bytes.NewReader(prefix[:n])) + if err != nil { + return nil, err + } + if !executable { + continue + } + file, err := classifyProducedContent(ctx, io.MultiReader(bytes.NewReader(prefix[:n]), reader), header.Size, candidates) + if err != nil { + return nil, err + } + if file.Digest != "" { + files = append(files, file) + } + } + if _, err := io.Copy(io.Discard, contents); err != nil { + return nil, fmt.Errorf("finish produced image layer: %w", err) + } + return files, nil +} + +func classifyProducedContent(ctx context.Context, reader io.Reader, size int64, candidates map[string]string) (ProducedFile, error) { + tmp, err := tmp_manager.TempFile("werf-sbom-produced-*.bin") + if err != nil { + return ProducedFile{}, fmt.Errorf("create produced binary spool: %w", err) + } + file, classifyErr := func() (ProducedFile, error) { + hash := sha256.New() + if _, err := io.Copy(io.MultiWriter(tmp, hash), reader); err != nil { + return ProducedFile{}, fmt.Errorf("spool produced binary: %w", err) + } + digest := "sha256:" + hex.EncodeToString(hash.Sum(nil)) + path, ok := candidates[digest] + if !ok { + return ProducedFile{}, nil + } + classification, err := ClassifyBinary(ctx, tmp, size) + if err != nil { + return ProducedFile{}, fmt.Errorf("classify produced binary: %w", err) + } + if classification.TestBinary || classification.Go == VerdictNone && classification.Rust == VerdictNone { + return ProducedFile{}, nil + } + return ProducedFile{Path: path, Digest: digest, Classification: classification}, nil + }() + if err := errors.Join(classifyErr, tmp.Close(), os.Remove(tmp.Name())); err != nil { + return ProducedFile{}, fmt.Errorf("read produced binary content: %w", err) + } + return file, nil +} diff --git a/pkg/sbom/shippedscope/produced_overlay_test.go b/pkg/sbom/shippedscope/produced_overlay_test.go new file mode 100644 index 0000000000..bb20494e19 --- /dev/null +++ b/pkg/sbom/shippedscope/produced_overlay_test.go @@ -0,0 +1,85 @@ +package shippedscope + +import ( + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("Produced stage overlay boundaries", func() { + ginkgo.DescribeTable("recognizes a file restored after an excluded stage removed its original entry", func(ctx ginkgo.SpecContext, excluded tarEntry) { + binary := string(goBinary()) + img, err := imageOf([]tarEntry{regularFile("bin/app", binary)}, []tarEntry{excluded}, []tarEntry{regularFile("bin/app", binary)}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{Known: true, Ranges: []ProducedLayerRange{{Start: 2, End: 3}}}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + }, + ginkgo.Entry("symlink replacement", symlink("bin/app", "elsewhere")), + ginkgo.Entry("directory whiteout", whiteout(".wh.bin")), + ginkgo.Entry("root opaque whiteout", whiteout(".wh..wh..opq")), + ginkgo.Entry("directory replacement", directory("bin/app")), + ) + + ginkgo.It("keeps cross-layer hardlink uncertainty even when its current target appears intact", func(ctx ginkgo.SpecContext) { + img, err := imageOf([]tarEntry{regularFile("z", string(goBinary()))}, []tarEntry{hardlink("a", "z")}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{Known: true, Ranges: []ProducedLayerRange{{Start: 0, End: 1}}}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(index.Incomplete).To(gomega.BeTrue()) + gomega.Expect(produced.Known).To(gomega.BeFalse()) + }) + + ginkgo.DescribeTable("does not lose surviving bytes when a cross-layer hardlink target changes", func(ctx ginkgo.SpecContext, removed bool) { + last := regularFile("z", "replacement") + if removed { + last = whiteout(".wh.z") + } + img, err := imageOf([]tarEntry{regularFile("z", string(goBinary()))}, []tarEntry{hardlink("a", "z")}, []tarEntry{last}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(index.Incomplete).To(gomega.BeTrue()) + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{Known: true, Ranges: []ProducedLayerRange{{Start: 1, End: 2}}}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(produced.Known).To(gomega.BeFalse()) + }, ginkgo.Entry("replaced target", false), ginkgo.Entry("deleted target", true)) + + ginkgo.It("ignores identical bytes rewritten at the same path", func(ctx ginkgo.SpecContext) { + body := string(goBinary()) + img, err := imageOf([]tarEntry{regularFile("app", body)}, []tarEntry{regularFile("app", body)}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{Known: true, Ranges: []ProducedLayerRange{{Start: 1, End: 2}}}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.BeEmpty()) + }) + + ginkgo.It("does not infer complete production from an incomplete intermediate snapshot", func(ctx ginkgo.SpecContext) { + img, err := imageOf([]tarEntry{hardlink("dangling", "absent")}, []tarEntry{whiteout(".wh.dangling"), regularFile("app", string(goBinary()))}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(index.Incomplete).To(gomega.BeFalse()) + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{Known: true, Ranges: []ProducedLayerRange{{Start: 1, End: 2}}}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(produced.Known).To(gomega.BeFalse()) + }) + + ginkgo.It("accepts an eligible stage with no appended layers", func(ctx ginkgo.SpecContext) { + img, err := imageOf([]tarEntry{regularFile("app", string(goBinary()))}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{Known: true, Ranges: []ProducedLayerRange{{Start: 0, End: 1}, {Start: 1, End: 1}}}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + }) +}) diff --git a/pkg/sbom/shippedscope/produced_test.go b/pkg/sbom/shippedscope/produced_test.go new file mode 100644 index 0000000000..88bcf37e75 --- /dev/null +++ b/pkg/sbom/shippedscope/produced_test.go @@ -0,0 +1,538 @@ +package shippedscope + +import ( + "bytes" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/werf/werf/v3/pkg/werf" +) + +var _ = ginkgo.BeforeEach(func() { + gomega.Expect(werf.Init(ginkgo.GinkgoT().TempDir(), "")).To(gomega.Succeed()) +}) + +var _ = ginkgo.Describe("CollectProducedFiles", func() { + ginkgo.It("returns unknown when the layer attribution is unknown", func(ctx ginkgo.SpecContext) { + img, err := imageOf([]tarEntry{regularFile("bin/app", string(goBinary()))}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{Known: false}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeFalse()) + gomega.Expect(produced.Files).To(gomega.BeEmpty()) + }) + + ginkgo.It("returns checked-empty when attribution is known and no ranges are eligible", func(ctx ginkgo.SpecContext) { + img, err := imageOf([]tarEntry{regularFile("bin/app", string(goBinary()))}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{Known: true, Ranges: nil}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.BeEmpty()) + }) + + ginkgo.It("collects a binary added in a produced range", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{regularFile("bin/app", string(binary))}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 2}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + gomega.Expect(produced.Files[0].Path).To(gomega.Equal("/bin/app")) + gomega.Expect(produced.Files[0].Digest).To(gomega.Equal(digestOf(string(binary)))) + gomega.Expect(produced.Files[0].Classification.Go).To(gomega.Equal(VerdictNames)) + }) + + ginkgo.It("ignores non-binary files in produced ranges", func(ctx ginkgo.SpecContext) { + img, err := imageOf( + []tarEntry{regularFile("etc/base", "base")}, + []tarEntry{regularFile("etc/config", "config"), regularFile("app.sh", "#!/bin/sh\necho hi\n")}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 2}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.BeEmpty()) + }) + + ginkgo.It("excludes a binary added in a produced range but deleted by a later layer", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{regularFile("bin/app", string(binary))}, + []tarEntry{whiteout("bin/.wh.app")}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 2}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.BeEmpty()) + }) + + ginkgo.It("excludes a binary whose content was replaced by a later layer", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{regularFile("bin/app", string(binary))}, + []tarEntry{regularFile("bin/app", "replaced")}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 2}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.BeEmpty()) + }) + + ginkgo.It("keeps a binary whose path was renamed but bytes survive elsewhere", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{regularFile("bin/app", string(binary))}, + []tarEntry{ + whiteout("bin/.wh.app"), + regularFile("bin/renamed", string(binary)), + }, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 2}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + gomega.Expect(produced.Files[0].Digest).To(gomega.Equal(digestOf(string(binary)))) + }) + + ginkgo.It("collects binaries from multiple produced ranges", func(ctx ginkgo.SpecContext) { + binaryA := goBinary() + binaryB := goTestingLinkedBinary() + gomega.Expect(binaryA).NotTo(gomega.Equal(binaryB)) + + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{regularFile("bin/app1", string(binaryA))}, + []tarEntry{regularFile("lib/import.so", "imported")}, + []tarEntry{regularFile("bin/app2", string(binaryB))}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{ + {Start: 1, End: 2}, + {Start: 3, End: 4}, + }, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(2)) + }) + + ginkgo.It("deduplicates identical binary content across ranges", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{regularFile("bin/app1", string(binary))}, + []tarEntry{regularFile("lib/import.so", "imported")}, + []tarEntry{regularFile("bin/app2", string(binary))}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{ + {Start: 1, End: 2}, + {Start: 3, End: 4}, + }, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + digests := make(map[string]bool) + for _, f := range produced.Files { + digests[f.Digest] = true + } + gomega.Expect(digests).To(gomega.HaveLen(1)) + }) + + ginkgo.It("collects a binary surviving via hardlink when the original target is replaced", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{ + regularFile("bin/app", string(binary)), + hardlink("bin/app.link", "bin/app"), + }, + []tarEntry{regularFile("bin/app", "replaced")}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 2}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + gomega.Expect(produced.Files[0].Digest).To(gomega.Equal(digestOf(string(binary)))) + }) + + ginkgo.It("collects a binary via hardlink when the link alone makes it survive", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{ + regularFile("bin/app", string(binary)), + hardlink("bin/backup", "bin/app"), + }, + []tarEntry{whiteout("bin/.wh.app")}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 2}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + gomega.Expect(produced.Files[0].Digest).To(gomega.Equal(digestOf(string(binary)))) + }) + + ginkgo.It("handles a produced range spanning multiple layers within one stage", func(ctx ginkgo.SpecContext) { + binaryA := goBinary() + binaryB := goTestingLinkedBinary() + gomega.Expect(binaryA).NotTo(gomega.Equal(binaryB)) + + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{regularFile("bin/part1", string(binaryA))}, + []tarEntry{regularFile("bin/part2", string(binaryB))}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 3}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(2)) + }) + + ginkgo.It("counts a binary copied to a new path by an eligible stage", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf( + []tarEntry{regularFile("bin/base", string(binary))}, + []tarEntry{regularFile("bin/copy", string(binary))}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 2}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(1), "a new path in the stage filesystem is an addition even if the bytes existed elsewhere") + }) + + ginkgo.It("collects a file that overwrites existing path with different binary content", func(ctx ginkgo.SpecContext) { + binaryA := goBinary() + binaryB := goTestingLinkedBinary() + gomega.Expect(binaryA).NotTo(gomega.Equal(binaryB)) + + img, err := imageOf( + []tarEntry{regularFile("bin/app", string(binaryA))}, + []tarEntry{regularFile("bin/app", string(binaryB))}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 2}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + gomega.Expect(produced.Files[0].Digest).To(gomega.Equal(digestOf(string(binaryB)))) + }) + + ginkgo.It("validates layer range bounds", func(ctx ginkgo.SpecContext) { + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{regularFile("bin/app", string(goBinary()))}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + _, err = CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 0, End: 10}}, + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + + ginkgo.It("validates inverted range bounds", func(ctx ginkgo.SpecContext) { + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{regularFile("bin/app", string(goBinary()))}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + _, err = CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 2, End: 1}}, + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + + ginkgo.It("returns deterministic output order", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{ + regularFile("bin/z", string(binary)), + regularFile("bin/a", string(goTestingLinkedBinary())), + }, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + layers := ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 2}}, + } + + var orders [][]string + for range 5 { + produced, err := CollectProducedFiles(ctx, img, index, layers) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + var paths []string + for _, f := range produced.Files { + paths = append(paths, f.Path) + } + orders = append(orders, paths) + } + + for i := 1; i < len(orders); i++ { + gomega.Expect(orders[i]).To(gomega.Equal(orders[0])) + } + }) + + ginkgo.It("does not treat test binaries as produced evidence", func(ctx ginkgo.SpecContext) { + testBinary := goTestBinary() + img, err := imageOf( + []tarEntry{regularFile("etc/config", "config")}, + []tarEntry{regularFile("bin/test", string(testBinary))}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 1, End: 2}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.BeEmpty()) + }) + + ginkgo.It("returns unknown when the file index is incomplete due to unresolved hardlinks", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf( + []tarEntry{ + regularFile("bin/app", string(binary)), + hardlink("bin/link", "bin/absent"), + }, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(index.Incomplete).To(gomega.BeTrue()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 0, End: 1}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Known).To(gomega.BeFalse()) + }) +}) + +var _ = ginkgo.Describe("CollectProducedFiles classification", func() { + ginkgo.It("classifies a Go binary with its modules", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf([]tarEntry{regularFile("bin/app", string(binary))}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 0, End: 1}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + gomega.Expect(produced.Files[0].Classification.Go).To(gomega.Equal(VerdictNames)) + gomega.Expect(produced.Files[0].Classification.Modules).To(gomega.ContainElement("example.test/lib")) + }) + + ginkgo.It("classifies binaries with unknown go/rust evidence", func(ctx ginkgo.SpecContext) { + wasmBinary := goBinaryFor("js/wasm") + img, err := imageOf([]tarEntry{regularFile("bin/app.wasm", string(wasmBinary))}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 0, End: 1}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + gomega.Expect(produced.Files[0].Classification.Go).To(gomega.Equal(VerdictUnknown)) + }) + + ginkgo.It("correctly handles hardlink-only surviving binary content for classification", func(ctx ginkgo.SpecContext) { + binary := goBinary() + img, err := imageOf( + []tarEntry{ + regularFile("bin/app", string(binary)), + hardlink("bin/backup", "bin/app"), + }, + []tarEntry{whiteout("bin/.wh.app")}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{ + Known: true, + Ranges: []ProducedLayerRange{{Start: 0, End: 1}}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + gomega.Expect(produced.Files[0].Classification.Go).To(gomega.Equal(VerdictNames)) + gomega.Expect(produced.Files[0].Classification.Modules).To(gomega.ContainElement("example.test/lib")) + }) +}) + +var _ = ginkgo.Describe("binary detection helpers", func() { + ginkgo.DescribeTable("formatOf identifies executable formats", + func(header []byte, expectExecutable bool) { + format, executable, err := formatOf(bytes.NewReader(header)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + if expectExecutable { + gomega.Expect(executable).To(gomega.BeTrue()) + gomega.Expect(format).NotTo(gomega.BeEmpty()) + } else { + gomega.Expect(executable).To(gomega.BeFalse()) + } + }, + ginkgo.Entry("ELF binary", []byte{0x7f, 'E', 'L', 'F', 0, 0, 0, 0}, true), + ginkgo.Entry("Mach-O 64-bit", []byte{0xcf, 0xfa, 0xed, 0xfe, 0, 0, 0, 0}, true), + ginkgo.Entry("PE binary", []byte{'M', 'Z', 0, 0, 0, 0, 0, 0}, true), + ginkgo.Entry("Shell script", []byte("#!/bin/sh\n"), false), + ginkgo.Entry("JSON config", []byte(`{"key": "value"}`), false), + ginkgo.Entry("Empty file", []byte{}, false), + ) +}) From f4f65f83b5fd24ac35af24cc457e923a8e47fd12 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Wed, 21 Oct 2026 23:12:47 +0300 Subject: [PATCH 2/7] feat(sbom): recover produced binaries from cached image stages Persist validated stage ancestry and layer boundaries for SBOM-enabled Stapel builds, including exact JSON transport through Docker commits. Regenerate binary evidence without rebuilding cached stages and fail open for older images or invalid ancestry. Signed-off-by: Radmir Khurum --- pkg/build/build_phase.go | 14 ++ pkg/build/helpers_test.go | 10 ++ pkg/build/sbom_provenance.go | 136 ++++++++++++++++ pkg/build/sbom_provenance_test.go | 152 ++++++++++++++++++ pkg/build/sbom_step.go | 104 ++++++++---- pkg/build/sbom_step_binary_test.go | 60 +++++++ pkg/build/sbom_step_checksum_test.go | 6 +- pkg/build/sbom_step_shipped_files_test.go | 36 +++-- pkg/build/stage/image_spec.go | 2 +- pkg/build/stage/image_spec_sbom_test.go | 24 +++ .../legacy_sbom_label_test.go | 34 ++++ .../legacy_stage_image_container_options.go | 6 +- pkg/image/const.go | 1 + 13 files changed, 540 insertions(+), 45 deletions(-) create mode 100644 pkg/build/sbom_provenance.go create mode 100644 pkg/build/sbom_provenance_test.go create mode 100644 pkg/build/sbom_step_binary_test.go create mode 100644 pkg/build/stage/image_spec_sbom_test.go create mode 100644 pkg/container_backend/legacy_sbom_label_test.go diff --git a/pkg/build/build_phase.go b/pkg/build/build_phase.go index b618675f09..adb390858b 100644 --- a/pkg/build/build_phase.go +++ b/pkg/build/build_phase.go @@ -3,6 +3,7 @@ package build import ( "bytes" "context" + "encoding/json" "errors" "fmt" "os" @@ -1828,6 +1829,19 @@ func (phase *BuildPhase) prepareStageInstructions(ctx context.Context, img *imag serviceLabels[imagePkg.WerfParentStageID] = baseStageImage.Image.GetStageDesc().StageID.String() } + if phase.Conveyor.EnableSbom() && img.StapelImageConfig != nil { + if _, ok := serviceLabels[imagePkg.WerfParentStageID]; !ok { + serviceLabels[imagePkg.WerfParentStageID] = "" + } + record, err := json.Marshal(sbomStageRecord{ + Stage: stg.Name(), StageDigest: stg.GetDigest(), ParentStageID: serviceLabels[imagePkg.WerfParentStageID], + }) + if err != nil { + return fmt.Errorf("encode SBOM stage record: %w", err) + } + serviceLabels[imagePkg.WerfSBOMStageLabel] = string(record) + } + // TODO: refactor this workaround required for the image spec stage. stageImage.Image.SetBuildServiceLabels(serviceLabels) diff --git a/pkg/build/helpers_test.go b/pkg/build/helpers_test.go index e0d363d376..72002e4830 100644 --- a/pkg/build/helpers_test.go +++ b/pkg/build/helpers_test.go @@ -12,6 +12,7 @@ import ( "sync" "sync/atomic" + cdx "github.com/CycloneDX/cyclonedx-go" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/registry" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -33,6 +34,7 @@ import ( "github.com/werf/werf/v3/pkg/giterminism_manager" imagePkg "github.com/werf/werf/v3/pkg/image" "github.com/werf/werf/v3/pkg/opstats" + "github.com/werf/werf/v3/pkg/sbom/shippedscope" "github.com/werf/werf/v3/pkg/storage" "github.com/werf/werf/v3/pkg/storage/manager" "github.com/werf/werf/v3/pkg/storage/synchronization/lock_manager" @@ -766,3 +768,11 @@ type publicationContainerBackend struct { var _ container_backend.ContainerBackend = (*publicationContainerBackend)(nil) func (b *publicationContainerBackend) HasStapelBuildSupport() bool { return true } + +func recordSavedShippedScope(ctx context.Context, bom *cdx.BOM, backend imageSaver, werfImgName, imageName string) { + if !shippedscope.HasFileBackedComponents(bom) && !shippedscope.HasBinaryBackedComponents(ctx, bom) { + return + } + filesets, produced := collectShippedScope(ctx, backend, werfImgName, imageName, sbomStageProvenance{}) + recordShippedScope(ctx, bom, filesets, produced, werfImgName) +} diff --git a/pkg/build/sbom_provenance.go b/pkg/build/sbom_provenance.go new file mode 100644 index 0000000000..db4c922ec1 --- /dev/null +++ b/pkg/build/sbom_provenance.go @@ -0,0 +1,136 @@ +package build + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + + "github.com/werf/common-go/pkg/util" + "github.com/werf/werf/v3/pkg/build/stage" + "github.com/werf/werf/v3/pkg/image" + "github.com/werf/werf/v3/pkg/sbom/shippedscope" +) + +type sbomStageRecord struct { + Stage stage.StageName `json:"stage"` + StageDigest string `json:"stageDigest"` + ParentStageID string `json:"parentStageID"` +} + +type sbomStageProvenance struct { + Layers shippedscope.ProducedLayers + DiffIDs []v1.Hash +} + +func resolveSBOMStageProvenance(ctx context.Context, final *image.StageDesc, readConfig func(context.Context, string) (*v1.ConfigFile, error)) (sbomStageProvenance, error) { + unknown := sbomStageProvenance{} + if final == nil || final.StageID == nil || final.Info == nil || final.Info.Repository == "" { + return unknown, fmt.Errorf("resolve SBOM stages: final stage descriptor is incomplete") + } + + ref := final.Info.RepoDigest + if ref == "" { + ref = final.Info.Name + } + current, err := readConfig(ctx, ref) + if err != nil { + return unknown, fmt.Errorf("read SBOM stage config %q: %w", ref, err) + } + if current == nil { + return unknown, fmt.Errorf("read SBOM stage config %q: empty config", ref) + } + + result := sbomStageProvenance{DiffIDs: slices.Clone(current.RootFS.DiffIDs)} + id := final.StageID.String() + digest := final.StageID.Digest + seen := make(map[string]bool) + stages := []stage.StageName{ + stage.From, stage.BeforeInstall, stage.DependenciesBeforeInstall, stage.GitArchive, + stage.Packages, stage.Install, stage.DependenciesAfterInstall, stage.BeforeSetup, + stage.DependenciesBeforeSetup, stage.Setup, stage.DependenciesAfterSetup, + stage.GitCache, stage.GitLatestPatch, stage.ImageSpec, stage.VerityAnnotation, stage.Sign, + } + previousPosition := len(stages) + for { + if err := ctx.Err(); err != nil { + return unknown, err + } + if seen[id] { + return unknown, fmt.Errorf("resolve SBOM stages: cyclic parent %q", id) + } + seen[id] = true + + var record sbomStageRecord + if err := json.Unmarshal([]byte(current.Config.Labels[image.WerfSBOMStageLabel]), &record); err != nil { + return unknown, fmt.Errorf("read SBOM stage record %q: %w", id, err) + } + if record.StageDigest != digest || record.ParentStageID != current.Config.Labels[image.WerfParentStageID] { + return unknown, fmt.Errorf("resolve SBOM stages: record does not belong to stage %q", id) + } + position := slices.Index(stages, record.Stage) + if position < 0 || position >= previousPosition { + return unknown, fmt.Errorf("resolve SBOM stages: unexpected stage %q at %q", record.Stage, id) + } + previousPosition = position + if record.Stage == stage.From { + result.Layers.Known = true + slices.Reverse(result.Layers.Ranges) + + return result, nil + } + + parentDigest, timestamp, ok := strings.Cut(record.ParentStageID, "-") + if !ok || parentDigest == "" { + return unknown, fmt.Errorf("resolve SBOM stages: invalid parent %q", record.ParentStageID) + } + if _, err := hex.DecodeString(parentDigest); err != nil { + return unknown, fmt.Errorf("read SBOM parent digest %q: %w", parentDigest, err) + } + if ts, err := strconv.ParseInt(timestamp, 10, 64); err != nil || ts <= 0 { + return unknown, fmt.Errorf("resolve SBOM stages: invalid parent timestamp %q", timestamp) + } + parentRef, err := name.NewTag(final.Info.Repository + ":" + record.ParentStageID) + if err != nil { + return unknown, fmt.Errorf("parse SBOM parent reference: %w", err) + } + parent, err := readConfig(ctx, parentRef.Name()) + if err != nil { + return unknown, fmt.Errorf("read SBOM parent config %q: %w", parentRef.Name(), err) + } + if parent == nil { + return unknown, fmt.Errorf("read SBOM parent config %q: empty config", parentRef.Name()) + } + parentLayers := parent.RootFS.DiffIDs + currentLayers := current.RootFS.DiffIDs + if len(parentLayers) > len(currentLayers) || !slices.Equal(parentLayers, currentLayers[:len(parentLayers)]) { + return unknown, fmt.Errorf("resolve SBOM stages: parent layers are not a prefix of stage %q", id) + } + if record.Stage == stage.Install || record.Stage == stage.BeforeSetup || record.Stage == stage.Setup { + result.Layers.Ranges = append(result.Layers.Ranges, shippedscope.ProducedLayerRange{Start: len(parentLayers), End: len(currentLayers)}) + } + + current = parent + id = record.ParentStageID + digest = parentDigest + } +} + +func sbomProvenanceChecksum(provenance sbomStageProvenance) string { + parts := []string{strconv.FormatBool(provenance.Layers.Known), "diffids"} + for _, diffID := range provenance.DiffIDs { + parts = append(parts, diffID.String()) + } + parts = append(parts, "ranges") + for _, layerRange := range provenance.Layers.Ranges { + parts = append(parts, fmt.Sprintf("%d:%d", layerRange.Start, layerRange.End)) + } + + return util.Sha256Hash(parts...) +} diff --git a/pkg/build/sbom_provenance_test.go b/pkg/build/sbom_provenance_test.go new file mode 100644 index 0000000000..ab5eb00d3d --- /dev/null +++ b/pkg/build/sbom_provenance_test.go @@ -0,0 +1,152 @@ +package build + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "strings" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/werf/werf/v3/pkg/build/stage" + "github.com/werf/werf/v3/pkg/image" + "github.com/werf/werf/v3/pkg/sbom/shippedscope" +) + +var _ = ginkgo.Describe("SBOM stage provenance", func() { + var configs map[string]*v1.ConfigFile + var final *image.StageDesc + var ids []string + var reads []string + var readConfig func(context.Context, string) (*v1.ConfigFile, error) + + ginkgo.BeforeEach(func() { + configs = make(map[string]*v1.ConfigFile) + ids = nil + reads = nil + stages := []stage.StageName{stage.From, stage.BeforeInstall, stage.Packages, stage.Install, stage.DependenciesAfterInstall, stage.Setup, stage.ImageSpec} + counts := []int{2, 3, 4, 6, 7, 8, 8} + var parent string + for i, stageName := range stages { + digest := strings.Repeat(fmt.Sprintf("%x", i+1), 56) + id := image.NewStageID(digest, int64(i+1)) + ids = append(ids, id.String()) + record, err := json.Marshal(sbomStageRecord{Stage: stageName, StageDigest: digest, ParentStageID: parent}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + cfg := &v1.ConfigFile{Config: v1.Config{Labels: map[string]string{ + image.WerfSBOMStageLabel: string(record), + image.WerfParentStageID: parent, + }}} + for j := range counts[i] { + cfg.RootFS.DiffIDs = append(cfg.RootFS.DiffIDs, v1.Hash{Algorithm: "sha256", Hex: strings.Repeat(fmt.Sprintf("%x", j+1), 64)}) + } + configs["example.com/stages:"+id.String()] = cfg + parent = id.String() + final = &image.StageDesc{StageID: id, Info: &image.Info{Repository: "example.com/stages", Name: "example.com/stages:" + id.String()}} + } + readConfig = func(ctx context.Context, ref string) (*v1.ConfigFile, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + reads = append(reads, ref) + cfg, ok := configs[ref] + if !ok { + return nil, fmt.Errorf("config %q missing", ref) + } + return cfg.DeepCopy(), nil + } + }) + + ginkgo.It("recovers eligible ranges from the published final image without intermediate stage objects", func(ctx ginkgo.SpecContext) { + provenance, err := resolveSBOMStageProvenance(ctx, final, readConfig) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(provenance.Layers).To(gomega.Equal(shippedscope.ProducedLayers{Known: true, Ranges: []shippedscope.ProducedLayerRange{{Start: 4, End: 6}, {Start: 7, End: 8}}})) + gomega.Expect(provenance.DiffIDs).To(gomega.Equal(configs[final.Info.Name].RootFS.DiffIDs)) + gomega.Expect(reads).To(gomega.HaveLen(len(ids))) + }) + + ginkgo.It("stops at this image's from stage instead of following an internal base", func(ctx ginkgo.SpecContext) { + from := configs["example.com/stages:"+ids[0]] + from.Config.Labels[image.WerfParentStageID] = "unavailable-base-1" + var record sbomStageRecord + gomega.Expect(json.Unmarshal([]byte(from.Config.Labels[image.WerfSBOMStageLabel]), &record)).To(gomega.Succeed()) + record.ParentStageID = "unavailable-base-1" + data, err := json.Marshal(record) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + from.Config.Labels[image.WerfSBOMStageLabel] = string(data) + provenance, err := resolveSBOMStageProvenance(ctx, final, readConfig) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(provenance.Layers.Known).To(gomega.BeTrue()) + }) + + ginkgo.DescribeTable("does not infer a complete set from invalid ancestry", func(ctx ginkgo.SpecContext, fault string) { + ref := "example.com/stages:" + ids[3] + cfg := configs[ref] + var record sbomStageRecord + gomega.Expect(json.Unmarshal([]byte(cfg.Config.Labels[image.WerfSBOMStageLabel]), &record)).To(gomega.Succeed()) + switch fault { + case "missing ancestor": + delete(configs, ref) + case "old cache": + delete(cfg.Config.Labels, image.WerfSBOMStageLabel) + case "malformed": + cfg.Config.Labels[image.WerfSBOMStageLabel] = "{" + case "inherited label": + cfg.Config.Labels[image.WerfSBOMStageLabel] = configs["example.com/stages:"+ids[2]].Config.Labels[image.WerfSBOMStageLabel] + case "recipe mismatch": + record.StageDigest = strings.Repeat("e", 56) + case "parent mismatch": + record.ParentStageID = ids[1] + case "rewritten layers": + cfg.RootFS.DiffIDs[0].Hex = strings.Repeat("f", 64) + case "cycle": + record.ParentStageID = ids[3] + cfg.Config.Labels[image.WerfParentStageID] = ids[3] + case "unknown stage": + record.Stage = "future-stage" + } + if fault == "recipe mismatch" || fault == "parent mismatch" || fault == "cycle" || fault == "unknown stage" { + data, err := json.Marshal(record) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + cfg.Config.Labels[image.WerfSBOMStageLabel] = string(data) + } + provenance, err := resolveSBOMStageProvenance(ctx, final, readConfig) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(provenance.Layers.Known).To(gomega.BeFalse()) + gomega.Expect(provenance.Layers.Ranges).To(gomega.BeEmpty()) + }, + ginkgo.Entry("missing ancestor", "missing ancestor"), + ginkgo.Entry("old cached image", "old cache"), + ginkgo.Entry("malformed record", "malformed"), + ginkgo.Entry("inherited record from an older builder", "inherited label"), + ginkgo.Entry("recipe digest mismatch", "recipe mismatch"), + ginkgo.Entry("parent binding mismatch", "parent mismatch"), + ginkgo.Entry("ELF signing rewrites a prefix", "rewritten layers"), + ginkgo.Entry("cyclic lineage", "cycle"), + ginkgo.Entry("unknown stage name", "unknown stage"), + ) + + ginkgo.It("invalidates the provenance checksum when a missing ancestor becomes available", func(ctx ginkgo.SpecContext) { + complete, err := resolveSBOMStageProvenance(ctx, final, readConfig) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + delete(configs, "example.com/stages:"+ids[3]) + missing, err := resolveSBOMStageProvenance(ctx, final, readConfig) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(sbomProvenanceChecksum(missing)).NotTo(gomega.Equal(sbomProvenanceChecksum(complete))) + }) + + ginkgo.It("resolves a copied stage chain from another repository", func(ctx ginkgo.SpecContext) { + for ref, cfg := range maps.Clone(configs) { + delete(configs, ref) + configs[strings.Replace(ref, "example.com/stages", "example.com/cache", 1)] = cfg + } + final.Info.Repository = "example.com/cache" + final.Info.Name = "example.com/cache:" + final.StageID.String() + provenance, err := resolveSBOMStageProvenance(ctx, final, readConfig) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(provenance.Layers.Known).To(gomega.BeTrue()) + }) +}) diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index 28efbd71a7..f8bd7a2191 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -25,6 +25,7 @@ import ( "github.com/werf/werf/v3/pkg/attestation" "github.com/werf/werf/v3/pkg/config" "github.com/werf/werf/v3/pkg/container_backend" + "github.com/werf/werf/v3/pkg/docker_registry" "github.com/werf/werf/v3/pkg/image" "github.com/werf/werf/v3/pkg/oci/artifact" "github.com/werf/werf/v3/pkg/sbom/cyclonedxutil" @@ -94,7 +95,15 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, return err } - checksum := step.calculateStableChecksum(scanOpts, mergeOpts, signerIdentity, targetPlatform) + var provenance sbomStageProvenance + if isStapel { + var err error + provenance, err = resolveSBOMStageProvenance(ctx, stageDesc, docker_registry.API().GetRepoImageConfigFile) + if err != nil { + logboek.Context(ctx).Debug().LogF("image %s: produced binaries are unknown: %s\n", werfImgName, err) + } + } + checksum := step.calculateStableChecksum(scanOpts, mergeOpts, signerIdentity, targetPlatform, provenance) store := artifact.NewOCIStore(repo, werfImgName) @@ -138,6 +147,19 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, cyclonedxutil.MarkWerfTool(targetBOM, werf.Version) } + var filesets *shippedscope.Filesets + produced := &shippedscope.ProducedFiles{} + inputs := append([]*cdx.BOM{targetBOM, mergeOpts.BaseBOM}, mergeOpts.ImportBOMs...) + if len(osPmPackages) > 0 || lo.SomeBy(inputs, func(bom *cdx.BOM) bool { + return bom != nil && (shippedscope.HasFileBackedComponents(bom) || shippedscope.HasBinaryBackedComponents(ctx, bom)) + }) { + filesets, produced = collectShippedScope(ctx, step.containerBackend, werfImgName, stageDesc.Info.Name, provenance) + } + mergeOpts, err = prepareBinaryMerge(ctx, targetBOM, mergeOpts, produced) + if err != nil { + return err + } + resultBOM := targetBOM if !mergeOpts.IsEmpty() { var err error @@ -207,7 +229,7 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, cyclonedxutil.Canonicalize(ctx, resultBOM) - recordShippedScope(ctx, resultBOM, step.containerBackend, werfImgName, stageDesc.Info.Name) + recordShippedScope(ctx, resultBOM, filesets, produced, werfImgName) resultJSON, err := cyclonedxutil.ToJSON(resultBOM) if err != nil { @@ -228,34 +250,13 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, }) } -// recordShippedScope records, on every component an ecosystem installed into the image, -// which files of the image are that component's, and then marks the components whose -// files are not in this image at all. 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, and nothing is removed from the BOM. 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 recordShippedScope(ctx context.Context, bom *cdx.BOM, backend imageSaver, werfImgName, imageName string) { - if !shippedscope.HasFileBackedComponents(bom) { - return - } - +func collectShippedScope(ctx context.Context, backend imageSaver, werfImgName, imageName string, provenance sbomStageProvenance) (*shippedscope.Filesets, *shippedscope.ProducedFiles) { + unknown := &shippedscope.ProducedFiles{} 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 + return nil, unknown } defer cleanup() @@ -263,9 +264,53 @@ func recordShippedScope(ctx context.Context, bom *cdx.BOM, backend imageSaver, w 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 + return nil, unknown + } + + if !provenance.Layers.Known { + return filesets, unknown + } + cfg, err := img.ConfigFile() + if err != nil || cfg == nil || !slices.Equal(cfg.RootFS.DiffIDs, provenance.DiffIDs) { + logboek.Context(ctx).Warn().LogF("WARNING: image %s: saved image does not match the stage provenance; produced binaries are unknown\n", werfImgName) + + return filesets, unknown + } + produced, err := shippedscope.CollectProducedFiles(ctx, img, filesets.Index, provenance.Layers) + if err != nil { + logboek.Context(ctx).Warn().LogF("WARNING: image %s: unable to collect produced binaries: %s\n", werfImgName, err) + + return filesets, unknown + } + + return filesets, produced +} + +func prepareBinaryMerge(ctx context.Context, target *cdx.BOM, opts cyclonedxutil.MergeOpts, produced *shippedscope.ProducedFiles) (cyclonedxutil.MergeOpts, error) { + shippedscope.PrepareBinaryOccurrences(ctx, target, produced) + inputs := append([]*cdx.BOM{opts.BaseBOM}, opts.ImportBOMs...) + for i, input := range inputs { + if input == nil { + continue + } + clone, err := cyclonedxutil.CloneBOM(input) + if err != nil { + return opts, fmt.Errorf("clone binary evidence source: %w", err) + } + shippedscope.NormalizeInheritedBinaryOccurrences(ctx, clone) + inputs[i] = clone } + opts.BaseBOM = inputs[0] + opts.ImportBOMs = inputs[1:] + return opts, nil +} + +func recordShippedScope(ctx context.Context, bom *cdx.BOM, filesets *shippedscope.Filesets, produced *shippedscope.ProducedFiles, werfImgName string) { + shippedscope.RecordProducedBinaryOccurrences(ctx, bom, produced) + if filesets == nil { + 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) @@ -553,7 +598,7 @@ func (step *sbomStep) recordDeclaredPackages(ctx context.Context, bom *cdx.BOM, return nil } -const sbomArtifactFormatVersion = "13" +const sbomArtifactFormatVersion = "14" // 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 @@ -565,7 +610,7 @@ const sbomArtifactFormatVersion = "13" // gomod patcher inputs (build context changes alter the stage digest), external reference // enrichment (non-deterministic external data), and generator logic changes (covered by // sbomArtifactFormatVersion). -func (step *sbomStep) calculateStableChecksum(scanOpts scanner.ScanOptions, mergeOpts cyclonedxutil.MergeOpts, signerIdentity, targetPlatform string) string { +func (step *sbomStep) calculateStableChecksum(scanOpts scanner.ScanOptions, mergeOpts cyclonedxutil.MergeOpts, signerIdentity, targetPlatform string, provenance sbomStageProvenance) string { return util.Sha256Hash( sbomArtifactFormatVersion, "scan", scanOpts.Checksum(), @@ -574,6 +619,7 @@ func (step *sbomStep) calculateStableChecksum(scanOpts scanner.ScanOptions, merg "gost_security_function", mergeOpts.Gost.SecurityFunction.String(), "signer", signerIdentity, "platform", targetPlatform, + "produced", sbomProvenanceChecksum(provenance), ) } diff --git a/pkg/build/sbom_step_binary_test.go b/pkg/build/sbom_step_binary_test.go new file mode 100644 index 0000000000..e1a7186b5a --- /dev/null +++ b/pkg/build/sbom_step_binary_test.go @@ -0,0 +1,60 @@ +package build + +import ( + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/samber/lo" + + "github.com/werf/werf/v3/pkg/sbom/cyclonedxutil" + "github.com/werf/werf/v3/pkg/sbom/shippedscope" +) + +var _ = ginkgo.Describe("SBOM binary merge wiring", func() { + var target, inherited *cdx.BOM + var filesets *shippedscope.Filesets + + ginkgo.BeforeEach(func() { + target = cyclonedxutil.NewBOM() + target.Components = &[]cdx.Component{{Name: "example.com/module", PackageURL: "pkg:golang/example.com/module@v1.0.0"}} + var err error + inherited, err = cyclonedxutil.CloneBOM(target) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + filesets = &shippedscope.Filesets{Index: &shippedscope.FileIndex{Files: map[string]shippedscope.File{}, Symlinks: map[string]string{}}} + }) + + ginkgo.It("does not turn an unknown inherited origin into the fresh target's checked-empty verdict", func(ctx ginkgo.SpecContext) { + original, err := cyclonedxutil.CloneBOM(inherited) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + produced := &shippedscope.ProducedFiles{Known: true} + opts, err := prepareBinaryMerge(ctx, target, cyclonedxutil.MergeOpts{BaseBOM: inherited}, produced) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + merged, err := cyclonedxutil.MergeBOMs(ctx, target, opts) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + recordShippedScope(ctx, merged, filesets, produced, "app") + + gomega.Expect(inherited).To(gomega.Equal(original)) + gomega.Expect(*merged.Components).To(gomega.HaveLen(1)) + gomega.Expect(lo.FromPtr((*merged.Components)[0].Properties)).To(gomega.ContainElement(cdx.Property{Name: "werf:binaries", Value: "unknown"})) + gomega.Expect(lo.FromPtr((*merged.Components)[0].Properties)).NotTo(gomega.ContainElement(cdx.Property{Name: "werf:shipped", Value: "false"})) + }) + + ginkgo.It("marks a fresh module when a complete build produced no binary", func(ctx ginkgo.SpecContext) { + produced := &shippedscope.ProducedFiles{Known: true} + opts, err := prepareBinaryMerge(ctx, target, cyclonedxutil.MergeOpts{}, produced) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(opts.IsEmpty()).To(gomega.BeTrue()) + recordShippedScope(ctx, target, filesets, produced, "app") + gomega.Expect(lo.FromPtr((*target.Components)[0].Properties)).To(gomega.ContainElement(cdx.Property{Name: "werf:shipped", Value: "false"})) + }) + + ginkgo.It("clears inherited negative binary verdicts when reading the current image fails", func(ctx ginkgo.SpecContext) { + (*inherited.Components)[0].Properties = &[]cdx.Property{ + {Name: "werf:binaries", Value: "none"}, + {Name: "werf:shipped", Value: "false"}, + } + recordShippedScope(ctx, inherited, nil, &shippedscope.ProducedFiles{}, "app") + gomega.Expect(lo.FromPtr((*inherited.Components)[0].Properties)).To(gomega.ContainElement(cdx.Property{Name: "werf:binaries", Value: "unknown"})) + gomega.Expect(lo.FromPtr((*inherited.Components)[0].Properties)).NotTo(gomega.ContainElement(cdx.Property{Name: "werf:shipped", Value: "false"})) + }) +}) diff --git a/pkg/build/sbom_step_checksum_test.go b/pkg/build/sbom_step_checksum_test.go index 6abbc2c397..4c468c93b6 100644 --- a/pkg/build/sbom_step_checksum_test.go +++ b/pkg/build/sbom_step_checksum_test.go @@ -16,6 +16,7 @@ var _ = Describe("SbomStep Checksum", func() { mergeOpts cyclonedxutil.MergeOpts signerIdentity string targetPlatform string + provenance sbomStageProvenance } baseline := func() checksumInputs { @@ -27,7 +28,7 @@ var _ = Describe("SbomStep Checksum", func() { checksumOf := func(in checksumInputs) string { step := &sbomStep{} - return step.calculateStableChecksum(in.scanOpts, in.mergeOpts, in.signerIdentity, in.targetPlatform) + return step.calculateStableChecksum(in.scanOpts, in.mergeOpts, in.signerIdentity, in.targetPlatform, in.provenance) } It("same inputs produce same checksum", func() { @@ -62,6 +63,9 @@ var _ = Describe("SbomStep Checksum", func() { Entry("target platform", func(in *checksumInputs) { in.targetPlatform = "linux/amd64" }), + Entry("recovered stage provenance", func(in *checksumInputs) { + in.provenance.Layers.Known = true + }), ) It("GOST config changes checksum even without base and import BOMs", func() { diff --git a/pkg/build/sbom_step_shipped_files_test.go b/pkg/build/sbom_step_shipped_files_test.go index b6e3ba29d7..36ae22f712 100644 --- a/pkg/build/sbom_step_shipped_files_test.go +++ b/pkg/build/sbom_step_shipped_files_test.go @@ -146,7 +146,7 @@ var _ = Describe("recordShippedScope", func() { {BOMRef: "libc6", Name: "libc6", PackageURL: "pkg:deb/debian/libc6@2.36"}, }} - recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + recordSavedShippedScope(ctx, bom, backend, "app", "werf-stages:app") Expect(backend.saved).To(Equal([]string{"werf-stages:app"})) components := *bom.Components @@ -177,7 +177,7 @@ var _ = Describe("recordShippedScope", func() { }} out := logged(ctx, func(ctx context.Context) { - recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + recordSavedShippedScope(ctx, bom, backend, "app", "werf-stages:app") }) Expect(out).To(ContainSubstring("/var/lib/pm/files.json is not")) @@ -198,7 +198,7 @@ var _ = Describe("recordShippedScope", func() { }}}, }} - recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + recordSavedShippedScope(ctx, bom, backend, "app", "werf-stages:app") Expect(lo.FromPtr((*bom.Components)[0].Properties)).To(BeEmpty(), "the bytes are in the image, so the manifest naming a path they left must not mark the component") @@ -213,7 +213,7 @@ var _ = Describe("recordShippedScope", func() { {BOMRef: "tool", Name: "tool", PackageURL: "pkg:pypi/tool@1.0.0"}, }} - recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + recordSavedShippedScope(ctx, bom, backend, "app", "werf-stages:app") Expect(lo.FromPtr((*bom.Components)[0].Properties)).To(ContainElement(cdx.Property{Name: "werf:shipped", Value: "false"}), "a build toolchain removed by a later stage is not shipped, and its own manifest is all that could say so") @@ -232,7 +232,7 @@ var _ = Describe("recordShippedScope", func() { }, }} - recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + recordSavedShippedScope(ctx, bom, backend, "app", "werf-stages:app") Expect(lo.FromPtr((*bom.Components)[0].Properties)).To(BeEmpty()) }) @@ -251,7 +251,7 @@ var _ = Describe("recordShippedScope", func() { }} out := logged(ctx, func(ctx context.Context) { - recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + recordSavedShippedScope(ctx, bom, backend, "app", "werf-stages:app") }) components := *bom.Components @@ -269,7 +269,7 @@ var _ = Describe("recordShippedScope", func() { }}}, }} - recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + recordSavedShippedScope(ctx, bom, backend, "app", "werf-stages:app") Expect(lo.FromPtr((*bom.Components)[0].Properties)).To(BeEmpty()) }) @@ -281,7 +281,7 @@ var _ = Describe("recordShippedScope", func() { }} out := logged(ctx, func(ctx context.Context) { - recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + recordSavedShippedScope(ctx, bom, backend, "app", "werf-stages:app") }) Expect(out).To(ContainSubstring("image app: unable to read the built image werf-stages:app")) @@ -296,7 +296,7 @@ var _ = Describe("recordShippedScope", func() { }} out := logged(ctx, func(ctx context.Context) { - recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + recordSavedShippedScope(ctx, bom, backend, "app", "werf-stages:app") }) Expect(out).To(ContainSubstring("image app: unable to read the built image werf-stages:app")) @@ -315,7 +315,7 @@ var _ = Describe("recordShippedScope", func() { }} out := logged(ctx, func(ctx context.Context) { - recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + recordSavedShippedScope(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")) @@ -323,15 +323,27 @@ var _ = Describe("recordShippedScope", func() { Expect(savedTarballs()).To(BeEmpty()) }) + It("reads the image even when every component is binary-backed", func(ctx SpecContext) { + backend := backendWith(map[string]string{"/app/go.mod": "module example.com/app"}, nil) + bom := &cdx.BOM{Components: &[]cdx.Component{ + {BOMRef: "yaml", Name: "gopkg.in/yaml.v3", PackageURL: "pkg:golang/gopkg.in/yaml.v3@3.0.1"}, + }} + + recordSavedShippedScope(ctx, bom, backend, "app", "werf-stages:app") + + Expect(backend.saved).To(Equal([]string{"werf-stages:app"})) + Expect(lo.FromPtr((*bom.Components)[0].Properties)).To(ContainElement(cdx.Property{Name: "werf:binaries", Value: "unknown"})) + Expect(lo.FromPtr((*bom.Components)[0].Properties)).NotTo(ContainElement(cdx.Property{Name: "werf:shipped", Value: "false"})) + }) + 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) { - recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + recordSavedShippedScope(ctx, bom, backend, "app", "werf-stages:app") }) Expect(out).To(BeEmpty()) diff --git a/pkg/build/stage/image_spec.go b/pkg/build/stage/image_spec.go index 33aa9504e3..8d158409f2 100644 --- a/pkg/build/stage/image_spec.go +++ b/pkg/build/stage/image_spec.go @@ -259,7 +259,7 @@ func (s *ImageSpecStage) modifyLabels(ctx context.Context, labels, addLabels map continue } - if key == image.WerfLabel || key == image.WerfParentStageID || key == image.WerfStageContentDigestLabel || key == image.WerfProjectRepoCommitLabel { + if key == image.WerfLabel || key == image.WerfParentStageID || key == image.WerfStageContentDigestLabel || key == image.WerfProjectRepoCommitLabel || key == image.WerfSBOMStageLabel { if !keepEssentialWerfLabels { shouldPrintGlobalWarn = true } diff --git a/pkg/build/stage/image_spec_sbom_test.go b/pkg/build/stage/image_spec_sbom_test.go new file mode 100644 index 0000000000..f9f835e497 --- /dev/null +++ b/pkg/build/stage/image_spec_sbom_test.go @@ -0,0 +1,24 @@ +package stage + +import ( + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/werf/werf/v3/pkg/image" +) + +var _ = ginkgo.Describe("SBOM provenance labels", func() { + ginkgo.DescribeTable("survive imageSpec label removal", func(ctx ginkgo.SpecContext, pattern string) { + stg := ImageSpecStage{BaseStage: &BaseStage{stageImage: &StageImage{Image: NewLegacyImageStub()}}} + labels := map[string]string{image.WerfSBOMStageLabel: "bound-stage-record", "unrelated": "remove"} + + modified, err := stg.modifyLabels(ctx, labels, map[string]string{}, []string{pattern}, false) + + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(modified).To(gomega.HaveKeyWithValue(image.WerfSBOMStageLabel, "bound-stage-record")) + gomega.Expect(labels).To(gomega.HaveKeyWithValue(image.WerfSBOMStageLabel, "bound-stage-record")) + }, + ginkgo.Entry("exact reserved key", image.WerfSBOMStageLabel), + ginkgo.Entry("remove all labels", "/.*/"), + ) +}) diff --git a/pkg/container_backend/legacy_sbom_label_test.go b/pkg/container_backend/legacy_sbom_label_test.go new file mode 100644 index 0000000000..d3fd01fd8c --- /dev/null +++ b/pkg/container_backend/legacy_sbom_label_test.go @@ -0,0 +1,34 @@ +package container_backend + +import ( + "strings" + + "github.com/moby/buildkit/frontend/dockerfile/shell" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/werf/werf/v3/pkg/image" +) + +var _ = ginkgo.Describe("SBOM stage record in legacy Docker commit", func() { + ginkgo.DescribeTable("preserves JSON quotes through Dockerfile expansion", func(ctx ginkgo.SpecContext, exact bool) { + const record = `{"stage":"install","stageDigest":"abc","parentStageID":"def-1"}` + opts := &LegacyStageImageContainerOptions{Label: map[string]string{image.WerfSBOMStageLabel: record}} + prepared, err := opts.prepareCommitChanges(ctx, LegacyCommitChangeOptions{ExactValues: exact}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + for _, changes := range [][]string{prepared, opts.toCommitChanges(LegacyCommitChangeOptions{ExactValues: exact})} { + found := false + for _, change := range changes { + value, ok := strings.CutPrefix(change, "LABEL "+image.WerfSBOMStageLabel+"=") + if !ok { + continue + } + found = true + decoded, _, err := shell.NewLex('\\').ProcessWord(value, shell.EnvsFromSlice([]string{})) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(decoded).To(gomega.Equal(record)) + } + gomega.Expect(found).To(gomega.BeTrue()) + } + }, ginkgo.Entry("legacy interpretation", false), ginkgo.Entry("exact values", true)) +}) diff --git a/pkg/container_backend/legacy_stage_image_container_options.go b/pkg/container_backend/legacy_stage_image_container_options.go index c875953d0d..acec14910d 100644 --- a/pkg/container_backend/legacy_stage_image_container_options.go +++ b/pkg/container_backend/legacy_stage_image_container_options.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "sort" + + "github.com/werf/werf/v3/pkg/image" ) type LegacyCommitChangeOptions struct { @@ -213,7 +215,7 @@ func (co *LegacyStageImageContainerOptions) toCommitChanges(opts LegacyCommitCha } for _, k := range sortStrings(getKeys(co.Label)) { - args = append(args, fmt.Sprintf("LABEL %s=%s", k, defaultEscape(co.Label[k], opts.ExactValues))) + args = append(args, fmt.Sprintf("LABEL %s=%s", k, defaultEscape(co.Label[k], opts.ExactValues || k == image.WerfSBOMStageLabel))) } if co.Cmd != "" { @@ -255,7 +257,7 @@ func (co *LegacyStageImageContainerOptions) prepareCommitChanges(ctx context.Con } for _, k := range sortStrings(getKeys(co.Label)) { - args = append(args, fmt.Sprintf("LABEL %s=%s", k, defaultEscape(co.Label[k], opts.ExactValues))) + args = append(args, fmt.Sprintf("LABEL %s=%s", k, defaultEscape(co.Label[k], opts.ExactValues || k == image.WerfSBOMStageLabel))) } if co.Workdir != "" { diff --git a/pkg/image/const.go b/pkg/image/const.go index 248d236823..6602e24247 100644 --- a/pkg/image/const.go +++ b/pkg/image/const.go @@ -10,6 +10,7 @@ const ( WerfImportSourceExternalImagePrefix = "external-image" WerfDependencySourceStageIDLabelPrefix = "werf-dependency-stage-id-" WerfParentStageID = "werf.io/parent-stage-id" + WerfSBOMStageLabel = "werf.io/sbom-stage-v1" WerfCustomTagMetadataStageIDLabel = "stage-id" WerfCustomTagMetadataTag = "tag" From 4687f022235d46b34b789debffd3873338e19fda Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Wed, 21 Oct 2026 23:12:53 +0300 Subject: [PATCH 3/7] test(sbom): verify binary evidence across imports and cache reuse Cover selective import from a two-binary donor, a second import and a manifest-only image. Assert dependency verdicts and occurrences remain stable after SBOM cache hits and regeneration from cached stages, while inherited unknown evidence remains conservative. Signed-off-by: Radmir Khurum --- .../inject/produced_binaries/cmd/app1/main.go | 10 ++++ .../inject/produced_binaries/cmd/app2/main.go | 16 ++++++ .../_fixtures/inject/produced_binaries/go.mod | 13 +++++ .../_fixtures/inject/produced_binaries/go.sum | 14 +++++ .../inject/produced_binaries/werf.yaml | 48 +++++++++++++++++ test/e2e/sbom/helpers_test.go | 46 ++++++++++++++++ test/e2e/sbom/produced_binaries_test.go | 53 +++++++++++++++++++ 7 files changed, 200 insertions(+) create mode 100644 test/e2e/sbom/_fixtures/inject/produced_binaries/cmd/app1/main.go create mode 100644 test/e2e/sbom/_fixtures/inject/produced_binaries/cmd/app2/main.go create mode 100644 test/e2e/sbom/_fixtures/inject/produced_binaries/go.mod create mode 100644 test/e2e/sbom/_fixtures/inject/produced_binaries/go.sum create mode 100644 test/e2e/sbom/_fixtures/inject/produced_binaries/werf.yaml create mode 100644 test/e2e/sbom/produced_binaries_test.go diff --git a/test/e2e/sbom/_fixtures/inject/produced_binaries/cmd/app1/main.go b/test/e2e/sbom/_fixtures/inject/produced_binaries/cmd/app1/main.go new file mode 100644 index 0000000000..f28d87419f --- /dev/null +++ b/test/e2e/sbom/_fixtures/inject/produced_binaries/cmd/app1/main.go @@ -0,0 +1,10 @@ +package main + +import "github.com/spf13/cobra" + +func main() { + cmd := &cobra.Command{Use: "app1"} + if err := cmd.Execute(); err != nil { + panic(err) + } +} diff --git a/test/e2e/sbom/_fixtures/inject/produced_binaries/cmd/app2/main.go b/test/e2e/sbom/_fixtures/inject/produced_binaries/cmd/app2/main.go new file mode 100644 index 0000000000..3669210afb --- /dev/null +++ b/test/e2e/sbom/_fixtures/inject/produced_binaries/cmd/app2/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +func main() { + data := map[string]string{"key": "value"} + out, err := yaml.Marshal(data) + if err != nil { + panic(err) + } + fmt.Println(string(out)) +} diff --git a/test/e2e/sbom/_fixtures/inject/produced_binaries/go.mod b/test/e2e/sbom/_fixtures/inject/produced_binaries/go.mod new file mode 100644 index 0000000000..2c4a000eca --- /dev/null +++ b/test/e2e/sbom/_fixtures/inject/produced_binaries/go.mod @@ -0,0 +1,13 @@ +module example.com/produced + +go 1.21 + +require ( + github.com/spf13/cobra v1.5.0 + gopkg.in/yaml.v3 v3.0.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/test/e2e/sbom/_fixtures/inject/produced_binaries/go.sum b/test/e2e/sbom/_fixtures/inject/produced_binaries/go.sum new file mode 100644 index 0000000000..d48a5be4aa --- /dev/null +++ b/test/e2e/sbom/_fixtures/inject/produced_binaries/go.sum @@ -0,0 +1,14 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= +github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/e2e/sbom/_fixtures/inject/produced_binaries/werf.yaml b/test/e2e/sbom/_fixtures/inject/produced_binaries/werf.yaml new file mode 100644 index 0000000000..e54dbc8c18 --- /dev/null +++ b/test/e2e/sbom/_fixtures/inject/produced_binaries/werf.yaml @@ -0,0 +1,48 @@ +project: werf-test-e2e-sbom-produced-binaries +configVersion: 1 +build: + cacheVersion: produced-binaries-v1 + sbom: + enable: true + standard: cyclonedx@1.6 +--- +image: donor +from: registry.deckhouse.io/container-factory@sha256:ef3bd2afef793bbaf31ce020550e577c7190cc7cb4cd9b3b3e9188587e3612cd +git: + - add: / + to: /app + stageDependencies: + packages: + - go.mod + - go.sum +shell: + install: + - cd /app && CGO_ENABLED=0 go build -o /bin/app1 ./cmd/app1 + - cd /app && CGO_ENABLED=0 go build -o /bin/app2 ./cmd/app2 +packages: + - type: go-mod + workdir: /app +--- +image: consumer +from: registry.werf.io/werf/scratch:latest +import: + - image: donor + add: /bin/app1 + to: /usr/bin/app1 + before: setup +--- +image: grandchild +from: registry.werf.io/werf/scratch:latest +import: + - image: consumer + add: /usr/bin/app1 + to: /app1 + before: setup +--- +image: manifest-only +from: registry.werf.io/werf/scratch:latest +import: + - image: donor + add: /app/go.mod + to: /app/go.mod + before: setup diff --git a/test/e2e/sbom/helpers_test.go b/test/e2e/sbom/helpers_test.go index 683e4a03e8..4cd91602cf 100644 --- a/test/e2e/sbom/helpers_test.go +++ b/test/e2e/sbom/helpers_test.go @@ -7,6 +7,7 @@ import ( "os" "slices" + cdx "github.com/CycloneDX/cyclonedx-go" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -14,10 +15,13 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote/transport" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/samber/lo" "github.com/werf/werf/v3/pkg/attestation" "github.com/werf/werf/v3/pkg/oci/artifact" sbomImage "github.com/werf/werf/v3/pkg/sbom/image" + "github.com/werf/werf/v3/pkg/sbom/shippedscope" + sbomtest "github.com/werf/werf/v3/test/pkg/sbom" "github.com/werf/werf/v3/test/pkg/werf" ) @@ -130,3 +134,45 @@ func mustExtractInTotoSubjectDigest(dsseEnvelope []byte) string { Expect(hasSha256).To(BeTrue(), "in-toto subject must carry a sha256 digest") return "sha256:" + hex } + +type producedBinarySnapshot struct { + Shipped bool + Unknown bool + Occurrences []cdx.EvidenceOccurrence +} + +func assertProducedBinaryImages(ctx SpecContext, project *werf.Project) map[string]producedBinarySnapshot { + snapshot := make(map[string]producedBinarySnapshot) + for _, imageName := range []string{"donor", "consumer", "grandchild", "manifest-only"} { + bom := sbomtest.MustParseSBOMOutput(project.SbomGet(ctx, &werf.SbomGetOptions{CommonOptions: werf.CommonOptions{ExtraArgs: []string{imageName}}})) + for module, version := range map[string]string{ + "github.com/spf13/cobra": "v1.5.0", + "github.com/spf13/pflag": "v1.0.5", + "github.com/inconshreveable/mousetrap": "v1.1.0", + "gopkg.in/yaml.v3": "v3.0.0", + } { + comp := sbomtest.FindComponent(bom, module, version) + Expect(comp).NotTo(BeNil(), "%s must retain component %s", imageName, module) + shipped := imageName != "manifest-only" && module != "github.com/inconshreveable/mousetrap" && (imageName == "donor" || module != "gopkg.in/yaml.v3") + properties := lo.FromPtr(comp.Properties) + negative := cdx.Property{Name: shippedscope.PropertyShipped, Value: "false"} + unknown := cdx.Property{Name: shippedscope.PropertyBinaries, Value: "unknown"} + if module == "github.com/inconshreveable/mousetrap" { + Expect(properties).To(ContainElement(unknown), "the pinned base has this same PURL without binary evidence") + Expect(properties).NotTo(ContainElement(negative), "local checked-empty cannot erase an unknown inherited origin") + } else if shipped { + Expect(properties).NotTo(ContainElement(negative), "%s must ship %s", imageName, module) + Expect(comp.Evidence).NotTo(BeNil()) + Expect(lo.FromPtr(comp.Evidence.Occurrences)).To(ContainElement(HaveField("AdditionalContext", MatchRegexp(`^werf:binary:v1:sha256:[0-9a-f]{64}$`)))) + } else { + Expect(properties).To(ContainElement(negative), "%s must mark %s not shipped", imageName, module) + } + var occurrences []cdx.EvidenceOccurrence + if comp.Evidence != nil { + occurrences = lo.FromPtr(comp.Evidence.Occurrences) + } + snapshot[imageName+"/"+module] = producedBinarySnapshot{Shipped: !slices.Contains(properties, negative), Unknown: slices.Contains(properties, unknown), Occurrences: occurrences} + } + } + return snapshot +} diff --git a/test/e2e/sbom/produced_binaries_test.go b/test/e2e/sbom/produced_binaries_test.go new file mode 100644 index 0000000000..bf2870363d --- /dev/null +++ b/test/e2e/sbom/produced_binaries_test.go @@ -0,0 +1,53 @@ +package e2e_build_test + +import ( + "os" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + sbomImage "github.com/werf/werf/v3/pkg/sbom/image" + "github.com/werf/werf/v3/test/pkg/werf" +) + +var _ = ginkgo.Describe("SBOM produced-binaries shipped-scope", ginkgo.Label("e2e", "sbom", "produced-binaries"), func() { + ginkgo.It("retains only the imported binary's dependencies through regeneration and a second import", func(ctx ginkgo.SpecContext) { + setupSbomBuildEnv() + repoDirname := "repo_sbom_produced_binaries" + SuiteData.InitTestRepo(ctx, repoDirname, "inject/produced_binaries") + project := werf.NewProject(SuiteData.WerfBinPath, SuiteData.GetTestRepoPath(repoDirname)) + + ginkgo.By("building two binaries and importing only the cobra binary") + out := project.Build(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{}}) + gomega.Expect(out).To(gomega.ContainSubstring("image donor: SBOM processing")) + initial := assertProducedBinaryImages(ctx, project) + donorDigest := stagesImageDigestOf(ctx, project, "donor") + + ginkgo.By("reusing both image and SBOM caches") + out = project.Build(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{}}) + gomega.Expect(out).To(gomega.ContainSubstring("Use previously built image")) + gomega.Expect(out).NotTo(gomega.ContainSubstring("Building stage")) + gomega.Expect(out).To(gomega.ContainSubstring("image donor: Use previously generated SBOM from registry")) + gomega.Expect(assertProducedBinaryImages(ctx, project)).To(gomega.Equal(initial)) + + ginkgo.By("deleting only the donor's SBOM discovery index, not its cached image") + repo := os.Getenv("WERF_REPO") + gomega.Expect(repo).NotTo(gomega.BeEmpty()) + tagRef, err := name.NewTag(repo+":"+sbomImage.FallbackTag(donorDigest), name.Insecure) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + desc, err := remote.Head(tagRef, insecureRemoteOptions(ctx)...) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(remote.Delete(tagRef.Context().Digest(desc.Digest.String()), insecureRemoteOptions(ctx)...)).To(gomega.Succeed()) + expectNoSbomArtifact(ctx, repo, donorDigest) + + ginkgo.By("regenerating donor evidence from cached stage ancestry without executing a build stage") + out = project.Build(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{}}) + gomega.Expect(out).To(gomega.ContainSubstring("image donor: SBOM processing")) + gomega.Expect(out).To(gomega.ContainSubstring("Use previously built image")) + gomega.Expect(out).NotTo(gomega.ContainSubstring("Building stage")) + gomega.Expect(stagesImageDigestOf(ctx, project, "donor")).To(gomega.Equal(donorDigest)) + gomega.Expect(assertProducedBinaryImages(ctx, project)).To(gomega.Equal(initial)) + }) +}) From da082709d22f5b200337d1fb2470b917529930fe Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Thu, 22 Oct 2026 11:16:48 +0300 Subject: [PATCH 4/7] fix(sbom): preserve complete evidence for same-layer hardlink chains Resolve chains and captured content using only their owning layer. Keep flattened fallback incomplete so overwritten or cross-layer targets cannot turn surviving binary content into a false absence. Signed-off-by: Radmir Khurum --- pkg/sbom/shippedscope/file_index.go | 48 ++++++++++++----- .../shippedscope/file_index_chains_test.go | 52 +++++++++++++++++++ pkg/sbom/shippedscope/file_index_test.go | 1 + 3 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 pkg/sbom/shippedscope/file_index_chains_test.go diff --git a/pkg/sbom/shippedscope/file_index.go b/pkg/sbom/shippedscope/file_index.go index 4db41a1270..4e0866a903 100644 --- a/pkg/sbom/shippedscope/file_index.go +++ b/pkg/sbom/shippedscope/file_index.go @@ -183,7 +183,18 @@ func resolveHardlinksWithinLayers(ctx context.Context, img v1.Image, index *File remaining := make(map[string]string, len(hardlinks)) for name, target := range hardlinks { remaining[name] = target - targets[target] = targets[target] || capture(name) + keepBody := capture(name) + for !targets[target] { + targets[target] = keepBody + if !keepBody { + break + } + next, ok := hardlinks[target] + if !ok { + break + } + target = next + } } layers, err := img.Layers() @@ -292,19 +303,30 @@ func resolveHardlinksInLayer(ctx context.Context, layer v1.Layer, index *FileInd // been read to the end. func resolveOwnedHardlinks(index *FileIndex, remaining, owned map[string]string, files map[string]File, bodies map[string][]byte, symlinks map[string]string) { for name, target := range owned { - if file, ok := files[target]; ok { - index.Files[name] = file - if body, ok := bodies[target]; ok { - index.bodies[name] = body + visited := map[string]bool{name: true} + for { + if file, ok := files[target]; ok { + index.Files[name] = file + files[name] = file + if body, ok := bodies[target]; ok { + index.bodies[name] = body + bodies[name] = body + } + delete(remaining, name) + break } - delete(remaining, name) - - continue - } - - if link, ok := symlinks[target]; ok { - index.Symlinks[name] = link - delete(remaining, name) + if link, ok := symlinks[target]; ok { + index.Symlinks[name] = link + symlinks[name] = link + delete(remaining, name) + break + } + next, ok := owned[target] + if !ok || visited[target] { + break + } + visited[target] = true + target = next } } } diff --git a/pkg/sbom/shippedscope/file_index_chains_test.go b/pkg/sbom/shippedscope/file_index_chains_test.go new file mode 100644 index 0000000000..388cbcf3b3 --- /dev/null +++ b/pkg/sbom/shippedscope/file_index_chains_test.go @@ -0,0 +1,52 @@ +package shippedscope + +import ( + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("Same-layer hardlink chains", func() { + ginkgo.It("keeps captured historical bytes after the terminal target is replaced", func(ctx ginkgo.SpecContext) { + img, err := imageOf([]tarEntry{hardlink("a", "b"), hardlink("b", "origin"), regularFile("origin", "old")}, []tarEntry{regularFile("origin", "new")}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + index, captured, err := indexImageFiles(ctx, img, func(name string) bool { return name == "/a" }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(index.Incomplete).To(gomega.BeFalse()) + gomega.Expect(index.Files["/a"].Digest).To(gomega.Equal(digestOf("old"))) + gomega.Expect(index.Files["/b"].Digest).To(gomega.Equal(digestOf("old"))) + gomega.Expect(index.Files["/origin"].Digest).To(gomega.Equal(digestOf("new"))) + gomega.Expect(captured).To(gomega.HaveKeyWithValue("/a", []byte("old"))) + }) + + ginkgo.It("keeps chained symlinks bound to their owning layer", func(ctx ginkgo.SpecContext) { + img, err := imageOf([]tarEntry{hardlink("a", "b"), hardlink("b", "origin"), symlink("origin", "old")}, []tarEntry{symlink("origin", "new")}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(index.Incomplete).To(gomega.BeFalse()) + gomega.Expect(index.Symlinks).To(gomega.HaveKeyWithValue("/a", "old")) + gomega.Expect(index.Files).NotTo(gomega.HaveKey("/a")) + }) + + ginkgo.It("does not claim completeness when an intermediate target only resolves in the flattened view", func(ctx ginkgo.SpecContext) { + img, err := imageOf([]tarEntry{hardlink("a", "b"), hardlink("b", "origin"), regularFile("origin", "old")}, []tarEntry{regularFile("b", "new")}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(index.Incomplete).To(gomega.BeTrue()) + }) + + ginkgo.It("classifies produced content that survives only through same-layer chains", func(ctx ginkgo.SpecContext) { + binary := string(goBinary()) + img, err := imageOf([]tarEntry{hardlink("a", "b"), hardlink("b", "origin"), regularFile("origin", binary)}, []tarEntry{whiteout(".wh.origin")}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + produced, err := CollectProducedFiles(ctx, img, index, ProducedLayers{Known: true, Ranges: []ProducedLayerRange{{Start: 0, End: 1}}}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + gomega.Expect(produced.Files[0].Digest).To(gomega.Equal(digestOf(binary))) + gomega.Expect(produced.Files[0].Classification.Go).To(gomega.Equal(VerdictNames)) + }) +}) diff --git a/pkg/sbom/shippedscope/file_index_test.go b/pkg/sbom/shippedscope/file_index_test.go index 3a0fbb1131..e74fcf4fd0 100644 --- a/pkg/sbom/shippedscope/file_index_test.go +++ b/pkg/sbom/shippedscope/file_index_test.go @@ -223,6 +223,7 @@ var _ = Describe("IndexImageFiles", func() { index, err := IndexImageFiles(ctx, img) Expect(err).NotTo(HaveOccurred()) + Expect(index.Incomplete).To(BeFalse()) for i := range 6 { Expect(index.Files[fmt.Sprintf("/usr/bin/link%d", i)].Digest).To(Equal(digestOf("shared"))) } From 6f49d044f04a3d644ad625f5223dc786be76a4aa Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Thu, 22 Oct 2026 11:16:56 +0300 Subject: [PATCH 5/7] fix(sbom): skip non-produced layers during binary classification Limit classification to eligible layers while retaining the same-layer completeness guard. Report filesystem boundary passes, scanned layers, elapsed time and hardlink fallback at the default log level; keep prefix reconstruction unchanged. Signed-off-by: Radmir Khurum --- pkg/sbom/shippedscope/helpers_test.go | 12 ++++++ pkg/sbom/shippedscope/produced.go | 29 +++++++++++++- pkg/sbom/shippedscope/produced_scan_test.go | 44 +++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 pkg/sbom/shippedscope/produced_scan_test.go diff --git a/pkg/sbom/shippedscope/helpers_test.go b/pkg/sbom/shippedscope/helpers_test.go index 1cf0d50388..fb57ce4099 100644 --- a/pkg/sbom/shippedscope/helpers_test.go +++ b/pkg/sbom/shippedscope/helpers_test.go @@ -94,6 +94,18 @@ func imageOf(layers ...[]tarEntry) (v1.Image, error) { return img, nil } +var _ v1.Layer = (*countedLayer)(nil) + +type countedLayer struct { + v1.Layer + opens int +} + +func (layer *countedLayer) Uncompressed() (io.ReadCloser, error) { + layer.opens++ + return layer.Layer.Uncompressed() +} + func digestOf(body string) string { sum := sha256.Sum256([]byte(body)) diff --git a/pkg/sbom/shippedscope/produced.go b/pkg/sbom/shippedscope/produced.go index e29c422630..becb83dd5d 100644 --- a/pkg/sbom/shippedscope/produced.go +++ b/pkg/sbom/shippedscope/produced.go @@ -11,11 +11,13 @@ import ( "io" "os" "slices" + "time" 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/werf/logboek" "github.com/werf/werf/v3/pkg/tmp_manager" ) @@ -46,20 +48,37 @@ type ProducedFiles struct { // CollectProducedFiles compares each verified stage's filesystem boundaries and records // the binary content still present in the final image, including relocated hardlinks. +// Complete indexes resolve hardlinks only within their owning layer, so candidate bytes +// must be archived in an eligible range. Supporting cross-layer resolution would also +// require tracking source layers instead of restricting classification to these ranges. func CollectProducedFiles(ctx context.Context, img v1.Image, index *FileIndex, layers ProducedLayers) (*ProducedFiles, error) { - if !layers.Known || index.Incomplete { + if !layers.Known { + return &ProducedFiles{}, nil + } + if index.Incomplete { + logboek.Context(ctx).Default().LogF("Produced binaries are unknown: the final file index requires hardlink fallback\n") return &ProducedFiles{}, nil } imgLayers, err := img.Layers() if err != nil { return nil, fmt.Errorf("read image layers: %w", err) } + eligibleLayers := make([]bool, len(imgLayers)) for _, r := range layers.Ranges { if r.Start < 0 || r.Start > r.End || r.End > len(imgLayers) { return nil, fmt.Errorf("invalid layer range [%d,%d) for image with %d layers", r.Start, r.End, len(imgLayers)) } + for i := r.Start; i < r.End; i++ { + eligibleLayers[i] = true + } } + started := time.Now() + boundaryPasses, layerScans := 0, 0 + defer func() { + logboek.Context(ctx).Default().LogF("Produced binaries: %d filesystem boundary passes, %d scanned layers (%s)\n", boundaryPasses, layerScans, time.Since(started).Round(time.Millisecond)) + }() + indexes := map[int]*FileIndex{0: {Files: map[string]File{}, Symlinks: map[string]string{}}, len(imgLayers): index} finalDigests := indexByDigest(index) candidates := make(map[string]string) @@ -75,6 +94,7 @@ func CollectProducedFiles(ctx context.Context, img v1.Image, index *FileIndex, l if err != nil { return nil, fmt.Errorf("read stage filesystem boundary %d: %w", boundary, err) } + boundaryPasses++ boundaryIndex, err := IndexImageFiles(ctx, prefix) if err != nil { return nil, fmt.Errorf("index stage filesystem boundary %d: %w", boundary, err) @@ -83,6 +103,7 @@ func CollectProducedFiles(ctx context.Context, img v1.Image, index *FileIndex, l } before, after := indexes[r.Start], indexes[r.End] if before.Incomplete || after.Incomplete { + logboek.Context(ctx).Default().LogF("Produced binaries are unknown: a stage boundary requires hardlink fallback\n") return &ProducedFiles{}, nil } for path, file := range after.Files { @@ -102,7 +123,11 @@ func CollectProducedFiles(ctx context.Context, img v1.Image, index *FileIndex, l } found := make(map[string]ProducedFile) - for _, layer := range imgLayers { + for i, layer := range imgLayers { + if !eligibleLayers[i] { + continue + } + layerScans++ files, err := classifyProducedLayer(ctx, layer, candidates) if err != nil { return nil, err diff --git a/pkg/sbom/shippedscope/produced_scan_test.go b/pkg/sbom/shippedscope/produced_scan_test.go new file mode 100644 index 0000000000..191761c5dd --- /dev/null +++ b/pkg/sbom/shippedscope/produced_scan_test.go @@ -0,0 +1,44 @@ +package shippedscope + +import ( + "bytes" + + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/werf/logboek" +) + +var _ = ginkgo.Describe("Produced binary scan cost", func() { + ginkgo.It("classifies only eligible layers and reports the boundary reconstruction cost", func(ctx ginkgo.SpecContext) { + binary := string(goBinary()) + base, err := layerOf(regularFile("base", binary)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + eligible, err := layerOf(regularFile("app", binary)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + excluded, err := layerOf(regularFile("other", binary)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + baseCounted := &countedLayer{Layer: base} + eligibleCounted := &countedLayer{Layer: eligible} + excludedCounted := &countedLayer{Layer: excluded} + img, err := mutate.AppendLayers(empty.Image, baseCounted, eligibleCounted, excludedCounted) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + index, err := IndexImageFiles(ctx, img) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + baseCounted.opens, eligibleCounted.opens, excludedCounted.opens = 0, 0, 0 + var output bytes.Buffer + logCtx := logboek.NewContext(ctx, logboek.NewLogger(&output, &output)) + produced, err := CollectProducedFiles(logCtx, img, index, ProducedLayers{Known: true, Ranges: []ProducedLayerRange{{Start: 1, End: 2}}}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(produced.Known).To(gomega.BeTrue()) + gomega.Expect(produced.Files).To(gomega.HaveLen(1)) + gomega.Expect(produced.Files[0].Classification.Go).To(gomega.Equal(VerdictNames)) + gomega.Expect(baseCounted.opens).To(gomega.Equal(2), "the base is read only for the two filesystem boundaries") + gomega.Expect(eligibleCounted.opens).To(gomega.Equal(2), "one boundary pass and one classification pass") + gomega.Expect(excludedCounted.opens).To(gomega.BeZero(), "the final filesystem was already indexed") + gomega.Expect(output.String()).To(gomega.ContainSubstring("2 filesystem boundary passes, 1 scanned layers")) + gomega.Expect(output.String()).To(gomega.MatchRegexp(`Produced binaries:.*\([0-9].*\)`)) + }) +}) From b6a9ee3efa46273bdaf0badd20e337ed42134463 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Thu, 22 Oct 2026 11:17:06 +0300 Subject: [PATCH 6/7] test(sbom): guard real stage order and cache compatibility Feed all sixteen mapper-generated stages through provenance recovery, including imports and signing. Cover registry recovery changing the SBOM cache key and image-cache hits without the new provenance label. Signed-off-by: Radmir Khurum --- pkg/build/helpers_test.go | 45 +++++++++++++ pkg/build/introspect_anchor_test.go | 18 ++++++ pkg/build/sbom_provenance_test.go | 27 ++++++++ pkg/build/sbom_stage_order_test.go | 64 +++++++++++++++++++ .../sbom_stage_order/src/assets/b.txt | 1 + .../sbom_stage_order/src/config/a.yaml | 1 + .../testdata/sbom_stage_order/src/go.mod | 1 + .../testdata/sbom_stage_order/src/main.go | 1 + pkg/build/testdata/sbom_stage_order/werf.yaml | 43 +++++++++++++ 9 files changed, 201 insertions(+) create mode 100644 pkg/build/sbom_stage_order_test.go create mode 100644 pkg/build/testdata/sbom_stage_order/src/assets/b.txt create mode 100644 pkg/build/testdata/sbom_stage_order/src/config/a.yaml create mode 100644 pkg/build/testdata/sbom_stage_order/src/go.mod create mode 100644 pkg/build/testdata/sbom_stage_order/src/main.go create mode 100644 pkg/build/testdata/sbom_stage_order/werf.yaml diff --git a/pkg/build/helpers_test.go b/pkg/build/helpers_test.go index 72002e4830..10eb2c955b 100644 --- a/pkg/build/helpers_test.go +++ b/pkg/build/helpers_test.go @@ -25,12 +25,15 @@ import ( "github.com/werf/lockgate/pkg/distributed_locker" "github.com/werf/lockgate/pkg/distributed_locker/optimistic_locking_store" buildImage "github.com/werf/werf/v3/pkg/build/image" + "github.com/werf/werf/v3/pkg/build/signing" "github.com/werf/werf/v3/pkg/build/stage" + "github.com/werf/werf/v3/pkg/build/verify_annotation" "github.com/werf/werf/v3/pkg/config" "github.com/werf/werf/v3/pkg/container_backend" "github.com/werf/werf/v3/pkg/container_backend/stage_builder" "github.com/werf/werf/v3/pkg/docker_registry" "github.com/werf/werf/v3/pkg/git_repo" + "github.com/werf/werf/v3/pkg/git_repo/gitdata" "github.com/werf/werf/v3/pkg/giterminism_manager" imagePkg "github.com/werf/werf/v3/pkg/image" "github.com/werf/werf/v3/pkg/opstats" @@ -38,8 +41,50 @@ import ( "github.com/werf/werf/v3/pkg/storage" "github.com/werf/werf/v3/pkg/storage/manager" "github.com/werf/werf/v3/pkg/storage/synchronization/lock_manager" + "github.com/werf/werf/v3/pkg/true_git" + "github.com/werf/werf/v3/pkg/werf" + "github.com/werf/werf/v3/test/pkg/utils" ) +func newSBOMStageOrderImage(ctx context.Context) *buildImage.Image { + tmpDir, homeDir := ginkgo.GinkgoT().TempDir(), ginkgo.GinkgoT().TempDir() + gomega.Expect(werf.Init(tmpDir, homeDir)).To(gomega.Succeed()) + gomega.Expect(true_git.Init(ctx, true_git.Options{})).To(gomega.Succeed()) + gitDataManager, err := gitdata.GetHostGitDataManager(ctx) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(git_repo.Init(gitDataManager)).To(gomega.Succeed()) + projectDir := filepath.Join(tmpDir, "project") + gomega.Expect(os.CopyFS(projectDir, os.DirFS("testdata/sbom_stage_order"))).To(gomega.Succeed()) + for _, args := range [][]string{ + {"init", "--initial-branch=main"}, + {"config", "user.name", "Test"}, + {"config", "user.email", "test@example.com"}, + {"config", "commit.gpgsign", "false"}, + {"add", "."}, + {"commit", "-m", "initial"}, + } { + utils.RunSucceedCommand(ctx, projectDir, "git", args...) + } + head := strings.TrimSpace(utils.SucceedCommandOutputString(ctx, projectDir, "git", "rev-parse", "HEAD")) + repo, err := git_repo.OpenLocalRepo(ctx, "own", projectDir, git_repo.OpenLocalRepoOptions{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + giterminismManager, err := giterminism_manager.NewManager(ctx, "", projectDir, repo, head, giterminism_manager.NewManagerOptions{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + _, werfConfig, err := config.GetWerfConfig(ctx, "", "", "", giterminismManager, config.WerfConfigOptions{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + imageConfig, ok := werfConfig.GetImage("app").(config.StapelImageInterface) + gomega.Expect(ok).To(gomega.BeTrue()) + images, err := buildImage.MapStapelConfigToImages(ctx, werfConfig.Meta, imageConfig, "linux/amd64", false, buildImage.CommonImageOptions{ + GiterminismManager: giterminismManager, + ProjectDir: projectDir, ProjectName: werfConfig.Meta.Project, ContainerWerfDir: "/.werf", TmpDir: tmpDir, + VerityAnnotationOptions: verify_annotation.Options{Enabled: true}, + ManifestSigningOptions: signing.ManifestSigningOptions{Enabled: true}, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(images).To(gomega.HaveLen(1)) + return images[0] +} + func newFinalRepoBuildPhase(ctx context.Context) (*BuildPhase, *manager.StorageManager) { gomega.Expect(docker_registry.Init(ctx, false, false, nil, nil)).To(gomega.Succeed()) var storages []storage.StagesStorage diff --git a/pkg/build/introspect_anchor_test.go b/pkg/build/introspect_anchor_test.go index 98a2a6954c..455ac7bedd 100644 --- a/pkg/build/introspect_anchor_test.go +++ b/pkg/build/introspect_anchor_test.go @@ -6,6 +6,7 @@ import ( "github.com/werf/werf/v3/pkg/build/image" "github.com/werf/werf/v3/pkg/build/stage" + "github.com/werf/werf/v3/pkg/config" imagePkg "github.com/werf/werf/v3/pkg/image" "github.com/werf/werf/v3/pkg/werf" ) @@ -42,6 +43,23 @@ var _ = ginkgo.Describe("Introspecting a stage of an image reused by its content return phase, img } + ginkgo.DescribeTable("reuses pre-provenance caches with SBOM enabled", func(ctx ginkgo.SpecContext, reuseAnchor bool) { + phase, img := newPhase() + phase.Conveyor.werfConfig.Meta.Build.Sbom = &config.MetaBuildSbom{Enable: true} + img.StapelImageConfig = &config.StapelImage{StapelImageBase: &config.StapelImageBase{Name: "app"}} + if reuseAnchor { + gomega.Expect(phase.resolveAvailableContentAnchors(ctx)).To(gomega.Succeed()) + gomega.Expect(img.AnchorReused).To(gomega.BeTrue()) + gomega.Expect(img.GetContentTagDesc().Info.Labels).NotTo(gomega.HaveKey(imagePkg.WerfSBOMStageLabel)) + } else { + stg := img.GetStages()[1] + gomega.Expect(phase.onImageStage(ctx, img, stg)).To(gomega.Succeed()) + gomega.Expect(stg.GetMeta().Rebuilt).To(gomega.BeFalse()) + gomega.Expect(stg.GetStageImage().Image.GetStageDesc().Info.Labels).NotTo(gomega.HaveKey(imagePkg.WerfSBOMStageLabel)) + } + gomega.Expect(record.processedStages).To(gomega.BeEmpty()) + }, ginkgo.Entry("image content anchor", true), ginkgo.Entry("individual cached stage", false)) + ginkgo.DescribeTable("processes the stages instead of reusing the anchor", func(ctx ginkgo.SpecContext, target IntrospectTarget) { phase, img := newPhase(target) diff --git a/pkg/build/sbom_provenance_test.go b/pkg/build/sbom_provenance_test.go index ab5eb00d3d..a9d19431ad 100644 --- a/pkg/build/sbom_provenance_test.go +++ b/pkg/build/sbom_provenance_test.go @@ -13,6 +13,8 @@ import ( "github.com/werf/werf/v3/pkg/build/stage" "github.com/werf/werf/v3/pkg/image" + "github.com/werf/werf/v3/pkg/sbom/cyclonedxutil" + "github.com/werf/werf/v3/pkg/sbom/scanner" "github.com/werf/werf/v3/pkg/sbom/shippedscope" ) @@ -138,6 +140,31 @@ var _ = ginkgo.Describe("SBOM stage provenance", func() { gomega.Expect(sbomProvenanceChecksum(missing)).NotTo(gomega.Equal(sbomProvenanceChecksum(complete))) }) + ginkgo.It("recovers from a transient registry error before selecting the SBOM cache key", func(ctx ginkgo.SpecContext) { + read := readConfig + failNext := true + readConfig = func(ctx context.Context, ref string) (*v1.ConfigFile, error) { + if failNext && ref == "example.com/stages:"+ids[3] { + failNext = false + return nil, fmt.Errorf("temporary registry failure") + } + return read(ctx, ref) + } + failed, err := resolveSBOMStageProvenance(ctx, final, readConfig) + gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("temporary registry failure"))) + gomega.Expect(failed.Layers.Known).To(gomega.BeFalse()) + recovered, err := resolveSBOMStageProvenance(ctx, final, readConfig) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(recovered.Layers.Known).To(gomega.BeTrue()) + step := &sbomStep{} + failedKey := step.calculateStableChecksum(scanner.ScanOptions{}, cyclonedxutil.MergeOpts{}, "", "linux/amd64", failed) + recoveredKey := step.calculateStableChecksum(scanner.ScanOptions{}, cyclonedxutil.MergeOpts{}, "", "linux/amd64", recovered) + gomega.Expect(recoveredKey).NotTo(gomega.Equal(failedKey)) + again, err := resolveSBOMStageProvenance(ctx, final, readConfig) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(step.calculateStableChecksum(scanner.ScanOptions{}, cyclonedxutil.MergeOpts{}, "", "linux/amd64", again)).To(gomega.Equal(recoveredKey)) + }) + ginkgo.It("resolves a copied stage chain from another repository", func(ctx ginkgo.SpecContext) { for ref, cfg := range maps.Clone(configs) { delete(configs, ref) diff --git a/pkg/build/sbom_stage_order_test.go b/pkg/build/sbom_stage_order_test.go new file mode 100644 index 0000000000..a77b91191b --- /dev/null +++ b/pkg/build/sbom_stage_order_test.go @@ -0,0 +1,64 @@ +package build + +import ( + "context" + "encoding/json" + "fmt" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/werf/werf/v3/pkg/build/stage" + "github.com/werf/werf/v3/pkg/image" + "github.com/werf/werf/v3/pkg/sbom/shippedscope" +) + +var _ = ginkgo.Describe("SBOM provenance with actual Stapel stage order", func() { + ginkgo.It("accepts the full mapper-generated chain and excludes dependency stages", func(ctx ginkgo.SpecContext) { + img := newSBOMStageOrderImage(ctx) + configs := make(map[string]*v1.ConfigFile) + var final *image.StageDesc + var parentID string + var diffIDs []v1.Hash + var stageNames []stage.StageName + var expectedRanges []shippedscope.ProducedLayerRange + for i, stg := range img.GetStages() { + stageName := stg.Name() + stageNames = append(stageNames, stageName) + if stageName == stage.Install || stageName == stage.BeforeSetup || stageName == stage.Setup { + expectedRanges = append(expectedRanges, shippedscope.ProducedLayerRange{Start: i, End: i + 1}) + } + digest := fmt.Sprintf("%056x", i+1) + stageID := image.NewStageID(digest, int64(i+1)) + record, err := json.Marshal(sbomStageRecord{Stage: stageName, StageDigest: digest, ParentStageID: parentID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + diffIDs = append(diffIDs, v1.Hash{Algorithm: "sha256", Hex: fmt.Sprintf("%064x", i+1)}) + cfg := &v1.ConfigFile{Config: v1.Config{Labels: map[string]string{ + image.WerfSBOMStageLabel: string(record), image.WerfParentStageID: parentID, + }}} + cfg.RootFS.DiffIDs = append([]v1.Hash{}, diffIDs...) + ref := "example.com/stages:" + stageID.String() + configs[ref] = cfg + parentID = stageID.String() + final = &image.StageDesc{StageID: stageID, Info: &image.Info{Repository: "example.com/stages", Name: ref}} + } + gomega.Expect(stageNames).To(gomega.ConsistOf( + stage.From, stage.BeforeInstall, stage.DependenciesBeforeInstall, stage.GitArchive, + stage.Packages, stage.Install, stage.DependenciesAfterInstall, stage.BeforeSetup, + stage.DependenciesBeforeSetup, stage.Setup, stage.DependenciesAfterSetup, + stage.GitCache, stage.GitLatestPatch, stage.ImageSpec, stage.VerityAnnotation, stage.Sign, + )) + provenance, err := resolveSBOMStageProvenance(ctx, final, func(_ context.Context, ref string) (*v1.ConfigFile, error) { + cfg, ok := configs[ref] + if !ok { + return nil, fmt.Errorf("config %q missing", ref) + } + return cfg.DeepCopy(), nil + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(provenance.Layers.Known).To(gomega.BeTrue()) + gomega.Expect(provenance.Layers.Ranges).To(gomega.Equal(expectedRanges)) + gomega.Expect(provenance.Layers.Ranges).To(gomega.HaveLen(3)) + }) +}) diff --git a/pkg/build/testdata/sbom_stage_order/src/assets/b.txt b/pkg/build/testdata/sbom_stage_order/src/assets/b.txt new file mode 100644 index 0000000000..2bd69c0fe8 --- /dev/null +++ b/pkg/build/testdata/sbom_stage_order/src/assets/b.txt @@ -0,0 +1 @@ +asset diff --git a/pkg/build/testdata/sbom_stage_order/src/config/a.yaml b/pkg/build/testdata/sbom_stage_order/src/config/a.yaml new file mode 100644 index 0000000000..b4f46b72e1 --- /dev/null +++ b/pkg/build/testdata/sbom_stage_order/src/config/a.yaml @@ -0,0 +1 @@ +key: value diff --git a/pkg/build/testdata/sbom_stage_order/src/go.mod b/pkg/build/testdata/sbom_stage_order/src/go.mod new file mode 100644 index 0000000000..4fdd561d9f --- /dev/null +++ b/pkg/build/testdata/sbom_stage_order/src/go.mod @@ -0,0 +1 @@ +module example.test/order diff --git a/pkg/build/testdata/sbom_stage_order/src/main.go b/pkg/build/testdata/sbom_stage_order/src/main.go new file mode 100644 index 0000000000..06ab7d0f9a --- /dev/null +++ b/pkg/build/testdata/sbom_stage_order/src/main.go @@ -0,0 +1 @@ +package main diff --git a/pkg/build/testdata/sbom_stage_order/werf.yaml b/pkg/build/testdata/sbom_stage_order/werf.yaml new file mode 100644 index 0000000000..eb90c5dc81 --- /dev/null +++ b/pkg/build/testdata/sbom_stage_order/werf.yaml @@ -0,0 +1,43 @@ +project: order-test +configVersion: 1 +--- +image: dep +from: scratch +--- +image: app +from: alpine:3.20 +git: +- add: /src + to: /app + stageDependencies: + packages: [go.mod] + install: ["**/*.go"] + beforeSetup: ["config/**"] + setup: ["assets/**"] +shell: + beforeInstall: [echo beforeInstall] + install: [echo install] + beforeSetup: [echo beforeSetup] + setup: [echo setup] +packages: +- type: os-pm + spec: [curl] +import: +- from: dep + add: /src + to: /before-install + before: install +- from: dep + add: /src + to: /after-install + after: install +- from: dep + add: /src + to: /before-setup + before: setup +- from: dep + add: /src + to: /after-setup + after: setup +imageSpec: + author: order-test From 328ea41d72a6668cd26eacb505316e75e30ad33e Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Thu, 22 Oct 2026 11:17:07 +0300 Subject: [PATCH 7/7] docs(sbom): clarify persisted binary evidence contracts Signed-off-by: Radmir Khurum --- pkg/sbom/shippedscope/binary_occurrences.go | 6 ++++++ pkg/sbom/shippedscope/match.go | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/sbom/shippedscope/binary_occurrences.go b/pkg/sbom/shippedscope/binary_occurrences.go index 494725e4cf..20def184a4 100644 --- a/pkg/sbom/shippedscope/binary_occurrences.go +++ b/pkg/sbom/shippedscope/binary_occurrences.go @@ -11,6 +11,12 @@ import ( "github.com/samber/lo" ) +// PropertyBinaries records completeness of binary evidence: "none" means an origin +// checked its produced binaries and found no match; "unknown" means an origin could +// not establish complete evidence and is preserved when components are merged. +// Positive or digest-bound unknown occurrences replace "none", but do not erase an +// inherited "unknown". With neither a recognized property nor a valid binary occurrence, +// evidence is unknown; absence of this property alone does not prove anything. const PropertyBinaries = "werf:binaries" const ( diff --git a/pkg/sbom/shippedscope/match.go b/pkg/sbom/shippedscope/match.go index c78081daf2..9dc1945ead 100644 --- a/pkg/sbom/shippedscope/match.go +++ b/pkg/sbom/shippedscope/match.go @@ -104,7 +104,9 @@ type Decision struct { } // Match decides, for every component, whether the image ships it. Verdicts come back in -// the order of components. +// the order of components. Its inputs describe one normalized evidence set, not the +// completeness of each origin in a merged BOM. For persisted BOMs, use MarkNotShipped: +// it preserves per-origin binary uncertainty and calls Match for file-backed components. // // A component is dropped only when every check misses. Checks run from strongest to // weakest, each one there for the case the previous was defeated by a deliberate change to