From c929728dd1604020c686ce14ac9308d68685d599 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Tue, 20 Oct 2026 13:32:50 +0300 Subject: [PATCH 1/4] feat(sbom): mark the components whose files an image does not carry An image that imports part of another inherits the whole SBOM of the image it imports from, so packages whose files never arrived are listed as shipped. Every component an ecosystem can describe is now checked against the files of the built image and, when none of them is there, carries the property werf:shipped=false. The verdict belongs to the image that made it: one inherited from a donor is dropped before the image decides anew, or a package the donor did not carry would stay marked in an image that does. Nothing is removed from the BOM: this first increment records the verdict to be read on real builds before anything acts on it. A component nothing is known about is kept, as is every Go module and crate, which are proven by the binaries the build produced rather than by files of their own. A file list naming paths the image holds nothing at no longer replaces what a donor recorded, whose digests are the only way left to find the bytes where this image put them. Signed-off-by: Radmir Khurum --- pkg/build/sbom_step.go | 25 +- pkg/build/sbom_step_shipped_files_test.go | 98 +++++++- pkg/sbom/shippedscope/fileset.go | 6 + pkg/sbom/shippedscope/mark.go | 149 +++++++++++ pkg/sbom/shippedscope/mark_test.go | 236 ++++++++++++++++++ pkg/sbom/shippedscope/occurrences.go | 19 +- pkg/sbom/shippedscope/occurrences_test.go | 40 ++- .../pip_partial_import/requirements.txt | 1 + .../inject/pip_partial_import/werf.yaml | 29 +++ test/e2e/sbom/pip_test.go | 49 ++++ 10 files changed, 626 insertions(+), 26 deletions(-) create mode 100644 pkg/sbom/shippedscope/mark.go create mode 100644 pkg/sbom/shippedscope/mark_test.go create mode 100644 test/e2e/sbom/_fixtures/inject/pip_partial_import/requirements.txt create mode 100644 test/e2e/sbom/_fixtures/inject/pip_partial_import/werf.yaml diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index c73a706f0a..d75e95866c 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -207,7 +207,7 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, cyclonedxutil.Canonicalize(ctx, resultBOM) - recordShippedFiles(ctx, resultBOM, step.containerBackend, werfImgName, stageDesc.Info.Name) + recordShippedScope(ctx, resultBOM, step.containerBackend, werfImgName, stageDesc.Info.Name) resultJSON, err := cyclonedxutil.ToJSON(resultBOM) if err != nil { @@ -228,10 +228,11 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, }) } -// recordShippedFiles records, on every component an ecosystem installed into the image, -// which files of the image are that component's. An image built from this one can then -// ask of an inherited component whether its files are still there, instead of trusting -// that a component its base declared came along. +// 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 @@ -241,11 +242,11 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, // second time: the read is then as long as a save to local disk, and what can fail it is // the host — an image that is not there, a disk that is full — rather than the network. // -// Nothing here fails the build. A BOM without these records is the BOM werf wrote before -// it kept them, and a reader that finds no record of a component's files keeps the -// component, so the worst an unreadable image costs is the SBOM of the image built from -// this one being as complete as it used to be. -func recordShippedFiles(ctx context.Context, bom *cdx.BOM, backend imageSaver, werfImgName, imageName string) { +// 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 } @@ -267,6 +268,10 @@ func recordShippedFiles(ctx context.Context, bom *cdx.BOM, backend imageSaver, w 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) + + if marked := shippedscope.MarkNotShipped(ctx, bom, filesets); marked > 0 { + logboek.Context(ctx).Default().LogF("image %s: %d components of the SBOM are not shipped by the image\n", werfImgName, marked) + } } // savedImage reads the image out of the backend as a docker-save tarball. The tarball is diff --git a/pkg/build/sbom_step_shipped_files_test.go b/pkg/build/sbom_step_shipped_files_test.go index c92b9d3b82..ddff30e235 100644 --- a/pkg/build/sbom_step_shipped_files_test.go +++ b/pkg/build/sbom_step_shipped_files_test.go @@ -44,7 +44,7 @@ func (b *savedImageBackend) SaveImageToStream(_ context.Context, imageName strin return io.NopCloser(bytes.NewReader(b.tarball)), nil } -var _ = Describe("recordShippedFiles", func() { +var _ = Describe("recordShippedScope", func() { tarOf := func(files, symlinks map[string]string) []byte { var buf bytes.Buffer writer := tar.NewWriter(&buf) @@ -146,7 +146,7 @@ var _ = Describe("recordShippedFiles", func() { {BOMRef: "libc6", Name: "libc6", PackageURL: "pkg:deb/debian/libc6@2.36"}, }} - recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app") + recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") Expect(backend.saved).To(Equal([]string{"werf-stages:app"})) components := *bom.Components @@ -171,12 +171,92 @@ var _ = Describe("recordShippedFiles", func() { }, nil) bom := &cdx.BOM{Components: &[]cdx.Component{ - {BOMRef: "zlib", Name: "zlib", PackageURL: "pkg:generic/zlib@1.3.1"}, + {BOMRef: "zlib", Name: "zlib", PackageURL: "pkg:generic/zlib@1.3.1", Evidence: &cdx.Evidence{Occurrences: &[]cdx.EvidenceOccurrence{ + {Location: "/usr/lib/libz.so.1.3.1", AdditionalContext: digestOf("a body this image does not hold")}, + }}}, }} - recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app") + out := logged(ctx, func(ctx context.Context) { + recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + }) - Expect((*bom.Components)[0].Evidence).To(BeNil()) + Expect(out).To(ContainSubstring("/var/lib/pm/files.json is not")) + Expect(lo.FromPtr((*bom.Components)[0].Properties)).To(BeEmpty()) + Expect(lo.FromPtr((*bom.Components)[0].Evidence.Occurrences)).To(HaveLen(1), + "what the donor recorded is left as it was") + }) + + It("keeps a component whose files this image moved away from the paths its manifest declares", func(ctx SpecContext) { + backend := backendWith(map[string]string{ + "/usr/lib/python3/moved-1.0.0.dist-info/RECORD": "moved/__init__.py,sha256=x,12\n", + "/opt/relocated/moved/__init__.py": "moved body", + }, nil) + + bom := &cdx.BOM{Components: &[]cdx.Component{ + {BOMRef: "moved", Name: "moved", PackageURL: "pkg:pypi/moved@1.0.0", Evidence: &cdx.Evidence{Occurrences: &[]cdx.EvidenceOccurrence{ + {Location: "/usr/lib/python3/moved/__init__.py", AdditionalContext: digestOf("moved body")}, + }}}, + }} + + recordShippedScope(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") + }) + + It("clears a verdict a donor recorded on a component this image does ship", func(ctx SpecContext) { + backend := backendWith(map[string]string{"/bin/jq": "jq body"}, nil) + + bom := &cdx.BOM{Components: &[]cdx.Component{ + { + BOMRef: "jq", Name: "jq", PackageURL: "pkg:generic/jq@1.8.1", + Properties: &[]cdx.Property{{Name: "werf:shipped", Value: "false"}}, + Evidence: &cdx.Evidence{Occurrences: &[]cdx.EvidenceOccurrence{ + {Location: "/usr/bin/jq", AdditionalContext: digestOf("jq body")}, + }}, + }, + }} + + recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + + Expect(lo.FromPtr((*bom.Components)[0].Properties)).To(BeEmpty()) + }) + + It("marks a component the image's own file lists do not account for and whose files it does not hold", func(ctx SpecContext) { + backend := backendWith(map[string]string{ + "/usr/lib/python3/kept-1.0.0.dist-info/RECORD": "kept/__init__.py,sha256=x,12\n", + "/usr/lib/python3/kept/__init__.py": "kept body", + }, nil) + + bom := &cdx.BOM{Components: &[]cdx.Component{ + {BOMRef: "kept", Name: "kept", PackageURL: "pkg:pypi/kept@1.0.0"}, + {BOMRef: "gone", Name: "gone", PackageURL: "pkg:pypi/gone@2.0.0", Evidence: &cdx.Evidence{Occurrences: &[]cdx.EvidenceOccurrence{ + {Location: "/usr/lib/python3/gone/__init__.py", AdditionalContext: digestOf("gone body")}, + }}}, + }} + + out := logged(ctx, func(ctx context.Context) { + recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + }) + + components := *bom.Components + Expect(lo.FromPtr(components[0].Properties)).To(BeEmpty(), "the image holds the files this component declares") + Expect(lo.FromPtr(components[1].Properties)).To(ContainElement(cdx.Property{Name: "werf:shipped", Value: "false"})) + Expect(out).To(ContainSubstring("image app: 1 components of the SBOM are not shipped by the image")) + }) + + It("keeps a component whose files an import put somewhere else in the image", func(ctx SpecContext) { + backend := backendWith(map[string]string{"/bin/jq": "jq body"}, nil) + + bom := &cdx.BOM{Components: &[]cdx.Component{ + {BOMRef: "jq", Name: "jq", PackageURL: "pkg:generic/jq@1.8.1", Evidence: &cdx.Evidence{Occurrences: &[]cdx.EvidenceOccurrence{ + {Location: "/usr/bin/jq", AdditionalContext: digestOf("jq body")}, + }}}, + }} + + recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") + + Expect(lo.FromPtr((*bom.Components)[0].Properties)).To(BeEmpty()) }) It("warns and leaves the BOM as it was when the backend cannot save the image", func(ctx SpecContext) { @@ -186,7 +266,7 @@ var _ = Describe("recordShippedFiles", func() { }} out := logged(ctx, func(ctx context.Context) { - recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app") + recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") }) Expect(out).To(ContainSubstring("image app: unable to read the built image werf-stages:app")) @@ -201,7 +281,7 @@ var _ = Describe("recordShippedFiles", func() { }} out := logged(ctx, func(ctx context.Context) { - recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app") + recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") }) Expect(out).To(ContainSubstring("image app: unable to read the built image werf-stages:app")) @@ -220,7 +300,7 @@ var _ = Describe("recordShippedFiles", func() { }} out := logged(ctx, func(ctx context.Context) { - recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app") + recordShippedScope(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")) @@ -236,7 +316,7 @@ var _ = Describe("recordShippedFiles", func() { }} out := logged(ctx, func(ctx context.Context) { - recordShippedFiles(ctx, bom, backend, "app", "werf-stages:app") + recordShippedScope(ctx, bom, backend, "app", "werf-stages:app") }) Expect(out).To(BeEmpty()) diff --git a/pkg/sbom/shippedscope/fileset.go b/pkg/sbom/shippedscope/fileset.go index 1ba1efaab0..2bcc388e9d 100644 --- a/pkg/sbom/shippedscope/fileset.go +++ b/pkg/sbom/shippedscope/fileset.go @@ -37,6 +37,11 @@ type Fileset struct { type Filesets struct { Filesets []Fileset + // Index is what the image holds, the same read the filesets were drawn from. A consumer + // that has to look up a file the filesets do not name — a component's file under a path + // no manifest of this image declares — takes it from here rather than reading the image + // a second time. + Index *FileIndex // OSPMFileListMissing is set when the image carries os-pm packages but was installed // by a pm that recorded no file list for them, so none of them has a fileset here. A // consumer that would drop a component without a fileset must not do so for os-pm then. @@ -67,6 +72,7 @@ func CollectFilesets(ctx context.Context, img v1.Image) (*Filesets, error) { filesets := &Filesets{ Filesets: osPM, + Index: index, OSPMFileListMissing: missing, } filesets.Filesets = append(filesets.Filesets, pythonFilesets(ctx, index, manifests)...) diff --git a/pkg/sbom/shippedscope/mark.go b/pkg/sbom/shippedscope/mark.go new file mode 100644 index 0000000000..426739508c --- /dev/null +++ b/pkg/sbom/shippedscope/mark.go @@ -0,0 +1,149 @@ +package shippedscope + +import ( + "context" + "strings" + + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/samber/lo" + + "github.com/werf/logboek" +) + +// PropertyShipped is written on a component the image does not ship. Only the negative +// case is recorded: a component without the property is one the image ships, one nothing +// is known about, or one written by a werf that did not look. +// +// The property describes one image, so it is the verdict of the image whose SBOM carries +// it and of no other. A component inherited from a donor arrives carrying the donor's +// verdict, which says where the component was in the donor's image rather than in this +// one, and MarkNotShipped drops it before deciding anew. +const PropertyShipped = "werf:shipped" + +const propertyShippedFalse = "false" + +const digestPrefix = "sha256:" + +// MarkNotShipped decides, for every component of the BOM whose files an ecosystem can +// describe, whether this image ships it, and writes PropertyShipped on the ones it does +// not. Nothing is removed: a component the rule calls absent is still a component the +// image's SBOM declared, and this first increment records the verdict to be looked at on +// real builds before anything acts on it. +// +// Runs after RecordOccurrences, on the occurrences it leaves: for a component this image +// installed those are the files the image holds, and for one inherited from a donor whose +// 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. +func MarkNotShipped(ctx context.Context, bom *cdx.BOM, filesets *Filesets) int { + if bom == nil || filesets == nil || filesets.Index == nil { + return 0 + } + + var components []Component + var judged []*cdx.Component + + walkComponents(bom, func(comp *cdx.Component) { + removeProperty(comp, PropertyShipped) + + key, ok := keyOfComponent(comp) + if !ok { + 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. + if key.ecosystem == EcosystemOSPM && filesets.OSPMFileListMissing { + return + } + + components = append(components, Component{ + Ecosystem: key.ecosystem, + Name: key.name, + Version: key.version, + Occurrences: occurrencesOf(comp), + }) + judged = append(judged, comp) + }) + + if len(components) == 0 { + return 0 + } + + count := 0 + for i, decision := range Match(ctx, components, ProducedBinaries{}, filesets.Index) { + if decision.Shipped { + continue + } + + setProperty(judged[i], PropertyShipped, propertyShippedFalse) + count++ + + logboek.Context(ctx).Debug().LogF("not shipped %s %s@%s (%s): checked %d files, 0 matches\n", + components[i].Ecosystem, components[i].Name, components[i].Version, decision.Reason, decision.Checked) + } + + return count +} + +// occurrencesOf reads back what werf recorded of a component's files. An occurrence +// without a digest or the symlink marker came from somewhere else — a scanner noting where +// it saw a package, a document merged in — and says nothing about content; a component +// carrying none of werf's own is one nothing is known about, not one whose files were +// looked for and missed. +func occurrencesOf(comp *cdx.Component) []Occurrence { + if comp.Evidence == nil || comp.Evidence.Occurrences == nil { + return nil + } + + recorded := *comp.Evidence.Occurrences + occurrences := make([]Occurrence, 0, len(recorded)) + for _, occurrence := range recorded { + switch { + case occurrence.AdditionalContext == symlinkContext: + occurrences = append(occurrences, Occurrence{Path: occurrence.Location, Symlink: true}) + case strings.HasPrefix(occurrence.AdditionalContext, digestPrefix): + occurrences = append(occurrences, Occurrence{Path: occurrence.Location, Digest: occurrence.AdditionalContext}) + } + } + + if len(recorded) > 0 && len(occurrences) == 0 { + return nil + } + + return occurrences +} + +func setProperty(comp *cdx.Component, name, value string) { + properties := lo.FromPtr(comp.Properties) + for i := range properties { + if properties[i].Name == name { + properties[i].Value = value + comp.Properties = &properties + + return + } + } + + properties = append(properties, cdx.Property{Name: name, Value: value}) + comp.Properties = &properties +} + +func removeProperty(comp *cdx.Component, name string) { + properties := lo.FromPtr(comp.Properties) + kept := lo.Filter(properties, func(property cdx.Property, _ int) bool { + return property.Name != name + }) + if len(kept) == len(properties) { + return + } + + comp.Properties = &kept +} diff --git a/pkg/sbom/shippedscope/mark_test.go b/pkg/sbom/shippedscope/mark_test.go new file mode 100644 index 0000000000..509bf1ac85 --- /dev/null +++ b/pkg/sbom/shippedscope/mark_test.go @@ -0,0 +1,236 @@ +package shippedscope + +import ( + cdx "github.com/CycloneDX/cyclonedx-go" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/samber/lo" +) + +var _ = Describe("MarkNotShipped", func() { + componentOf := func(purl string, occurrences ...cdx.EvidenceOccurrence) cdx.Component { + comp := cdx.Component{BOMRef: purl, Name: purl, PackageURL: purl} + if occurrences != nil { + comp.Evidence = &cdx.Evidence{Occurrences: &occurrences} + } + + return comp + } + + bomOf := func(components ...cdx.Component) *cdx.BOM { + return &cdx.BOM{Components: &components} + } + + fileOccurrence := func(path, body string) cdx.EvidenceOccurrence { + return cdx.EvidenceOccurrence{Location: path, AdditionalContext: digestOf(body)} + } + + shippedProperty := func(bom *cdx.BOM, index int) (string, bool) { + for _, property := range lo.FromPtr((*bom.Components)[index].Properties) { + if property.Name == PropertyShipped { + return property.Value, true + } + } + + return "", false + } + + filesetsOf := func(index *FileIndex) *Filesets { + return &Filesets{Index: index} + } + + It("marks a component whose declared files are not in the image", func(ctx SpecContext) { + bom := bomOf( + componentOf("pkg:pypi/present@1.0.0", fileOccurrence("/app/present.py", "here")), + componentOf("pkg:pypi/gone@2.0.0", fileOccurrence("/app/gone.py", "elsewhere")), + ) + + marked := MarkNotShipped(ctx, bom, filesetsOf(indexOf(map[string]File{ + "/app/present.py": {Digest: digestOf("here"), Size: 4}, + }))) + + Expect(marked).To(Equal(1)) + _, present := shippedProperty(bom, 0) + Expect(present).To(BeFalse(), "a component the image ships carries no property") + value, found := shippedProperty(bom, 1) + Expect(found).To(BeTrue()) + Expect(value).To(Equal("false")) + }) + + It("marks a component whose files were looked for and none were found", func(ctx SpecContext) { + bom := bomOf(componentOf("pkg:pypi/gone@2.0.0")) + (*bom.Components)[0].Evidence = &cdx.Evidence{Occurrences: &[]cdx.EvidenceOccurrence{}} + + marked := MarkNotShipped(ctx, bom, filesetsOf(emptyIndex())) + + Expect(marked).To(Equal(1)) + value, found := shippedProperty(bom, 0) + Expect(found).To(BeTrue()) + Expect(value).To(Equal("false")) + }) + + It("keeps a component nothing is known about", func(ctx SpecContext) { + bom := bomOf(componentOf("pkg:pypi/unknown@1.0.0")) + + marked := MarkNotShipped(ctx, bom, filesetsOf(emptyIndex())) + + Expect(marked).To(BeZero()) + _, found := shippedProperty(bom, 0) + Expect(found).To(BeFalse()) + }) + + It("keeps a component whose only occurrences came from elsewhere", func(ctx SpecContext) { + bom := bomOf(componentOf("pkg:pypi/scanned@1.0.0", + cdx.EvidenceOccurrence{Location: "/app/scanned.py"}, + cdx.EvidenceOccurrence{Location: "/app/other.py", AdditionalContext: "seen by a scanner"}, + )) + + marked := MarkNotShipped(ctx, bom, filesetsOf(emptyIndex())) + + Expect(marked).To(BeZero()) + _, found := shippedProperty(bom, 0) + Expect(found).To(BeFalse()) + }) + + It("finds a component by content wherever an import put it", func(ctx SpecContext) { + bom := bomOf(componentOf("pkg:generic/jq@1.8.1", fileOccurrence("/usr/bin/jq", "jq"))) + + marked := MarkNotShipped(ctx, bom, filesetsOf(indexOf(map[string]File{ + "/bin/jq": {Digest: digestOf("jq"), Size: 2}, + }))) + + Expect(marked).To(BeZero()) + _, found := shippedProperty(bom, 0) + Expect(found).To(BeFalse()) + }) + + It("keeps every os-pm component when the image records no file lists", func(ctx SpecContext) { + bom := bomOf( + componentOf("pkg:generic/gone@1.0.0", fileOccurrence("/usr/lib/gone.so", "gone")), + componentOf("pkg:pypi/gone@2.0.0", fileOccurrence("/app/gone.py", "gone too")), + ) + + filesets := filesetsOf(emptyIndex()) + filesets.OSPMFileListMissing = true + + marked := MarkNotShipped(ctx, bom, filesets) + + Expect(marked).To(Equal(1), "only the component of an ecosystem the image does describe is judged") + _, found := shippedProperty(bom, 0) + Expect(found).To(BeFalse()) + _, found = shippedProperty(bom, 1) + Expect(found).To(BeTrue()) + }) + + It("keeps a Go module, whose binaries this image's produced set does not cover", func(ctx SpecContext) { + bom := bomOf(componentOf("pkg:golang/github.com/spf13/cobra@1.8.0")) + + marked := MarkNotShipped(ctx, bom, filesetsOf(emptyIndex())) + + Expect(marked).To(BeZero()) + _, found := shippedProperty(bom, 0) + Expect(found).To(BeFalse()) + }) + + It("marks a nested component", func(ctx SpecContext) { + nested := []cdx.Component{componentOf("pkg:pypi/gone@2.0.0", fileOccurrence("/app/gone.py", "gone"))} + outer := componentOf("pkg:pypi/outer@1.0.0", fileOccurrence("/app/outer.py", "outer")) + outer.Components = &nested + + bom := bomOf(outer) + + marked := MarkNotShipped(ctx, bom, filesetsOf(indexOf(map[string]File{ + "/app/outer.py": {Digest: digestOf("outer"), Size: 5}, + }))) + + Expect(marked).To(Equal(1)) + value, found := shippedProperty(bom, 0) + Expect(found).To(BeFalse(), "the outer component ships") + Expect(value).To(BeEmpty()) + + for _, property := range lo.FromPtr((*(*bom.Components)[0].Components)[0].Properties) { + if property.Name == PropertyShipped { + Expect(property.Value).To(Equal("false")) + + return + } + } + Fail("the nested component carries no shipped property") + }) + + It("clears a verdict a donor recorded when this image does ship the component", func(ctx SpecContext) { + comp := componentOf("pkg:pypi/present@1.0.0", fileOccurrence("/app/present.py", "here")) + comp.Properties = &[]cdx.Property{{Name: PropertyShipped, Value: "false"}} + + bom := bomOf(comp) + + marked := MarkNotShipped(ctx, bom, filesetsOf(indexOf(map[string]File{ + "/app/present.py": {Digest: digestOf("here"), Size: 4}, + }))) + + Expect(marked).To(BeZero()) + _, found := shippedProperty(bom, 0) + Expect(found).To(BeFalse(), "the donor's verdict describes the donor's image, not this one") + }) + + It("clears a verdict a donor recorded on a component this image does not judge", func(ctx SpecContext) { + module := componentOf("pkg:golang/github.com/spf13/cobra@1.8.0") + module.Properties = &[]cdx.Property{{Name: PropertyShipped, Value: "false"}} + osPM := componentOf("pkg:generic/zlib@1.3.1", fileOccurrence("/usr/lib/libz.so", "z")) + osPM.Properties = &[]cdx.Property{{Name: PropertyShipped, Value: "false"}} + kept := componentOf("pkg:deb/debian/libc6@2.36") + kept.Properties = &[]cdx.Property{{Name: PropertyShipped, Value: "false"}} + + bom := bomOf(module, osPM, kept) + + filesets := filesetsOf(emptyIndex()) + filesets.OSPMFileListMissing = true + + Expect(MarkNotShipped(ctx, bom, filesets)).To(BeZero()) + for i := range *bom.Components { + _, found := shippedProperty(bom, i) + Expect(found).To(BeFalse(), "component %d keeps a verdict this image never made", i) + } + }) + + It("leaves the component's other properties alone", func(ctx SpecContext) { + comp := componentOf("pkg:pypi/present@1.0.0", fileOccurrence("/app/present.py", "here")) + comp.Properties = &[]cdx.Property{ + {Name: "werf:package:foundBy", Value: "pip"}, + {Name: PropertyShipped, Value: "false"}, + } + + bom := bomOf(comp) + + MarkNotShipped(ctx, bom, filesetsOf(indexOf(map[string]File{ + "/app/present.py": {Digest: digestOf("here"), Size: 4}, + }))) + + Expect(lo.FromPtr((*bom.Components)[0].Properties)).To(Equal([]cdx.Property{ + {Name: "werf:package:foundBy", Value: "pip"}, + })) + }) + + It("replaces a property the component already carries", func(ctx SpecContext) { + comp := componentOf("pkg:pypi/gone@2.0.0", fileOccurrence("/app/gone.py", "gone")) + comp.Properties = &[]cdx.Property{ + {Name: "werf:package:foundBy", Value: "pm"}, + {Name: PropertyShipped, Value: "true"}, + } + + bom := bomOf(comp) + + Expect(MarkNotShipped(ctx, bom, filesetsOf(emptyIndex()))).To(Equal(1)) + Expect(lo.FromPtr((*bom.Components)[0].Properties)).To(HaveLen(2)) + value, found := shippedProperty(bom, 0) + Expect(found).To(BeTrue()) + Expect(value).To(Equal("false")) + }) + + It("does nothing without an index of the image", func(ctx SpecContext) { + bom := bomOf(componentOf("pkg:pypi/gone@2.0.0", fileOccurrence("/app/gone.py", "gone"))) + + Expect(MarkNotShipped(ctx, bom, &Filesets{})).To(BeZero()) + Expect((*bom.Components)[0].Properties).To(BeNil()) + }) +}) diff --git a/pkg/sbom/shippedscope/occurrences.go b/pkg/sbom/shippedscope/occurrences.go index dd2ae8a17e..54289045a3 100644 --- a/pkg/sbom/shippedscope/occurrences.go +++ b/pkg/sbom/shippedscope/occurrences.go @@ -26,7 +26,10 @@ import ( // // Occurrences the component already carries are replaced: a fileset is what the image // holds, and a scanner's guess at where it saw the package is not evidence of the same -// kind. +// kind. A fileset that names files the image does not hold at any of them replaces +// nothing: what a donor recorded of the same component is the only way left to find its +// bytes where this image put them, and an empty list in its place would read as the files +// having been looked for and missed. // // Returns the number of components given occurrences. func RecordOccurrences(bom *cdx.BOM, filesets []Fileset) int { @@ -56,7 +59,9 @@ func RecordOccurrences(bom *cdx.BOM, filesets []Fileset) int { return } - setOccurrences(comp, sets) + if !setOccurrences(comp, sets) { + return + } recorded++ }) @@ -155,8 +160,8 @@ const symlinkContext = "symlink" // setOccurrences records every file of every fileset of one component. npm installs a // package more than once under different paths, and each copy is a fileset of its own; -// what the component ships is all of them. -func setOccurrences(comp *cdx.Component, sets []Fileset) { +// what the component ships is all of them. Reports whether anything was recorded. +func setOccurrences(comp *cdx.Component, sets []Fileset) bool { contexts := make(map[string]string) for _, fileset := range sets { for file, entry := range fileset.Files { @@ -167,6 +172,10 @@ func setOccurrences(comp *cdx.Component, sets []Fileset) { } } + if len(contexts) == 0 { + return false + } + occurrences := make([]cdx.EvidenceOccurrence, 0, len(contexts)) for _, file := range slices.Sorted(maps.Keys(contexts)) { occurrences = append(occurrences, cdx.EvidenceOccurrence{ @@ -179,6 +188,8 @@ func setOccurrences(comp *cdx.Component, sets []Fileset) { comp.Evidence = &cdx.Evidence{} } comp.Evidence.Occurrences = &occurrences + + return true } func walkComponents(bom *cdx.BOM, visit func(comp *cdx.Component)) { diff --git a/pkg/sbom/shippedscope/occurrences_test.go b/pkg/sbom/shippedscope/occurrences_test.go index 8abf47d3d8..1fe1783771 100644 --- a/pkg/sbom/shippedscope/occurrences_test.go +++ b/pkg/sbom/shippedscope/occurrences_test.go @@ -103,6 +103,39 @@ var _ = Describe("RecordOccurrences", func() { Expect((*bom.Components)[0].Evidence).To(BeNil()) }) + It("keeps what a donor recorded when the image holds none of the files its manifest declares", func() { + bom := bomOf("pkg:pypi/requests@2.32.3") + (*bom.Components)[0].Evidence = &cdx.Evidence{Occurrences: &[]cdx.EvidenceOccurrence{ + {Location: "/usr/lib/python3/requests/__init__.py", AdditionalContext: digestOf("r")}, + }} + + recorded := RecordOccurrences(bom, []Fileset{{ + Ecosystem: EcosystemPython, + Name: "requests", + Version: "2.32.3", + DeclaresFiles: true, + }}) + + Expect(recorded).To(BeZero()) + Expect(occurrencesOf(bom, 0)).To(Equal([]cdx.EvidenceOccurrence{ + {Location: "/usr/lib/python3/requests/__init__.py", AdditionalContext: digestOf("r")}, + }), "the digests a donor recorded are the only way left to find the bytes this image moved") + }) + + It("says nothing of a component the image holds no declared file of and no donor described", func() { + bom := bomOf("pkg:pypi/requests@2.32.3") + + recorded := RecordOccurrences(bom, []Fileset{{ + Ecosystem: EcosystemPython, + Name: "requests", + Version: "2.32.3", + DeclaresFiles: true, + }}) + + Expect(recorded).To(BeZero()) + Expect((*bom.Components)[0].Evidence).To(BeNil()) + }) + It("records a component one of whose copies declares files", func() { bom := bomOf("pkg:npm/debug@4.3.4") @@ -184,15 +217,16 @@ var _ = Describe("RecordOccurrences", func() { })) }) - It("says the files were looked for when a fileset names a component but the image kept none", func() { + It("claims nothing of a component a fileset names but the image kept no file of", func() { bom := bomOf("pkg:generic/zlib@1.3.1") recorded := RecordOccurrences(bom, []Fileset{ fileset(EcosystemOSPM, "zlib", "1.3.1", nil, nil), }) - Expect(recorded).To(Equal(1)) - Expect((*bom.Components)[0].Evidence.Occurrences).To(Equal(&[]cdx.EvidenceOccurrence{})) + Expect(recorded).To(BeZero()) + Expect((*bom.Components)[0].Evidence).To(BeNil(), + "an empty list would read as the files having been looked for and missed, which an index that drops a file it cannot resolve would produce too") }) It("leaves a component no fileset names unmarked, so nothing is known of its files", func() { diff --git a/test/e2e/sbom/_fixtures/inject/pip_partial_import/requirements.txt b/test/e2e/sbom/_fixtures/inject/pip_partial_import/requirements.txt new file mode 100644 index 0000000000..d80d9fc2a3 --- /dev/null +++ b/test/e2e/sbom/_fixtures/inject/pip_partial_import/requirements.txt @@ -0,0 +1 @@ +requests==2.32.3 diff --git a/test/e2e/sbom/_fixtures/inject/pip_partial_import/werf.yaml b/test/e2e/sbom/_fixtures/inject/pip_partial_import/werf.yaml new file mode 100644 index 0000000000..0ecfebda2d --- /dev/null +++ b/test/e2e/sbom/_fixtures/inject/pip_partial_import/werf.yaml @@ -0,0 +1,29 @@ +project: werf-test-e2e-sbom-inject-pip-partial-import +configVersion: 1 +build: + sbom: + enable: true + standard: cyclonedx@1.6 +--- +image: donor +from: registry.deckhouse.io/container-factory@sha256:7aac8d97a91ac233b19111aaad9d7bba12b975e620cfa9287b97e0003e4d0a71 +git: + - add: / + to: /app + stageDependencies: + packages: + - requirements.txt +packages: + - type: os-pm + spec: + - python==3.12.12 + - type: python-pip + workdir: /app +--- +image: app +from: registry.werf.io/werf/scratch:latest +import: + - image: donor + add: /app/requirements.txt + to: /imported/requirements.txt + before: setup diff --git a/test/e2e/sbom/pip_test.go b/test/e2e/sbom/pip_test.go index 3acf317e2a..04e92873f6 100644 --- a/test/e2e/sbom/pip_test.go +++ b/test/e2e/sbom/pip_test.go @@ -1,9 +1,12 @@ package e2e_build_test 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/shippedscope" sbomtest "github.com/werf/werf/v3/test/pkg/sbom" "github.com/werf/werf/v3/test/pkg/werf" ) @@ -85,4 +88,50 @@ var _ = Describe("SBOM python-pip packages", Label("e2e", "sbom", "pip", "simple } Expect(occurrences).To(ContainElement(HaveField("Location", HaveSuffix("/requests/__init__.py")))) }) + + It("marks a package of the donor whose files the importing image does not carry", func(ctx SpecContext) { + setupSbomBuildEnv() + + repoDirname := "repo_sbom_python_pip_partial_import" + SuiteData.InitTestRepo(ctx, repoDirname, "inject/pip_partial_import") + testRepoPath := SuiteData.GetTestRepoPath(repoDirname) + + werfProject := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + werfProject.Build(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{}}) + + bomOf := func(image string) *cdx.BOM { + return sbomtest.MustParseSBOMOutput(werfProject.SbomGet(ctx, &werf.SbomGetOptions{ + CommonOptions: werf.CommonOptions{ExtraArgs: []string{image}}, + })) + } + + shippedProperty := func(comp *cdx.Component) (string, bool) { + for _, property := range lo.FromPtr(comp.Properties) { + if property.Name == shippedscope.PropertyShipped { + return property.Value, true + } + } + + return "", false + } + + donorBOM := bomOf("donor") + installed := sbomtest.FindComponent(donorBOM, "requests", "2.32.3") + Expect(installed).NotTo(BeNil(), "expected requests@2.32.3 in the BOM of the image that installs it") + _, marked := shippedProperty(installed) + Expect(marked).To(BeFalse(), "the image that installed requests holds its files, so it must not be marked") + + for _, comp := range lo.FromPtr(donorBOM.Components) { + _, marked := shippedProperty(&comp) + Expect(marked).To(BeFalse(), "the image that installed its packages ships them: %s@%s is marked", comp.Name, comp.Version) + } + + // The importing image takes one file of the donor and inherits the donor's whole BOM + // with it, requests included, while none of the files requests consists of came along. + inherited := sbomtest.FindComponent(bomOf("app"), "requests", "2.32.3") + Expect(inherited).NotTo(BeNil(), "expected requests@2.32.3 to be inherited into the BOM of the importing image") + value, marked := shippedProperty(inherited) + Expect(marked).To(BeTrue(), "requests is in the BOM of an image that carries none of its files, so it must be marked") + Expect(value).To(Equal("false")) + }) }) From 64386fc13f408eccaacdb45b082ff4b34d6a1056 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Tue, 20 Oct 2026 15:22:44 +0300 Subject: [PATCH 2/4] fix(sbom): mark a package the image installed and then deleted Keeping a donor's occurrences over a file list the image holds none of was done for every component, so a package this image installed and a later stage removed, whose own manifest is all that is left of it, was treated as one nothing is known about and kept. That is the toolchain cleanup the marking exists for. The exception now applies only over occurrences werf itself recorded before; over anything else the empty list is written, as it was. Signed-off-by: Radmir Khurum --- pkg/build/sbom_step_shipped_files_test.go | 15 ++++++++ pkg/sbom/shippedscope/mark.go | 33 +---------------- pkg/sbom/shippedscope/occurrences.go | 43 ++++++++++++++++++++--- pkg/sbom/shippedscope/occurrences_test.go | 29 +++++++++++---- 4 files changed, 76 insertions(+), 44 deletions(-) diff --git a/pkg/build/sbom_step_shipped_files_test.go b/pkg/build/sbom_step_shipped_files_test.go index ddff30e235..b6e3ba29d7 100644 --- a/pkg/build/sbom_step_shipped_files_test.go +++ b/pkg/build/sbom_step_shipped_files_test.go @@ -204,6 +204,21 @@ var _ = Describe("recordShippedScope", func() { "the bytes are in the image, so the manifest naming a path they left must not mark the component") }) + It("marks a component the image installed and then deleted, leaving only its manifest", func(ctx SpecContext) { + backend := backendWith(map[string]string{ + "/usr/lib/python3/tool-1.0.0.dist-info/RECORD": "tool/main.py,sha256=x,12\n", + }, nil) + + bom := &cdx.BOM{Components: &[]cdx.Component{ + {BOMRef: "tool", Name: "tool", PackageURL: "pkg:pypi/tool@1.0.0"}, + }} + + recordShippedScope(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") + }) + It("clears a verdict a donor recorded on a component this image does ship", func(ctx SpecContext) { backend := backendWith(map[string]string{"/bin/jq": "jq body"}, nil) diff --git a/pkg/sbom/shippedscope/mark.go b/pkg/sbom/shippedscope/mark.go index 426739508c..fde38fd7b2 100644 --- a/pkg/sbom/shippedscope/mark.go +++ b/pkg/sbom/shippedscope/mark.go @@ -2,7 +2,6 @@ package shippedscope import ( "context" - "strings" cdx "github.com/CycloneDX/cyclonedx-go" "github.com/samber/lo" @@ -22,8 +21,6 @@ const PropertyShipped = "werf:shipped" const propertyShippedFalse = "false" -const digestPrefix = "sha256:" - // MarkNotShipped decides, for every component of the BOM whose files an ecosystem can // describe, whether this image ships it, and writes PropertyShipped on the ones it does // not. Nothing is removed: a component the rule calls absent is still a component the @@ -68,7 +65,7 @@ func MarkNotShipped(ctx context.Context, bom *cdx.BOM, filesets *Filesets) int { Ecosystem: key.ecosystem, Name: key.name, Version: key.version, - Occurrences: occurrencesOf(comp), + Occurrences: recordedOccurrences(comp), }) judged = append(judged, comp) }) @@ -93,34 +90,6 @@ func MarkNotShipped(ctx context.Context, bom *cdx.BOM, filesets *Filesets) int { return count } -// occurrencesOf reads back what werf recorded of a component's files. An occurrence -// without a digest or the symlink marker came from somewhere else — a scanner noting where -// it saw a package, a document merged in — and says nothing about content; a component -// carrying none of werf's own is one nothing is known about, not one whose files were -// looked for and missed. -func occurrencesOf(comp *cdx.Component) []Occurrence { - if comp.Evidence == nil || comp.Evidence.Occurrences == nil { - return nil - } - - recorded := *comp.Evidence.Occurrences - occurrences := make([]Occurrence, 0, len(recorded)) - for _, occurrence := range recorded { - switch { - case occurrence.AdditionalContext == symlinkContext: - occurrences = append(occurrences, Occurrence{Path: occurrence.Location, Symlink: true}) - case strings.HasPrefix(occurrence.AdditionalContext, digestPrefix): - occurrences = append(occurrences, Occurrence{Path: occurrence.Location, Digest: occurrence.AdditionalContext}) - } - } - - if len(recorded) > 0 && len(occurrences) == 0 { - return nil - } - - return occurrences -} - func setProperty(comp *cdx.Component, name, value string) { properties := lo.FromPtr(comp.Properties) for i := range properties { diff --git a/pkg/sbom/shippedscope/occurrences.go b/pkg/sbom/shippedscope/occurrences.go index 54289045a3..0b7f5f5041 100644 --- a/pkg/sbom/shippedscope/occurrences.go +++ b/pkg/sbom/shippedscope/occurrences.go @@ -26,10 +26,13 @@ import ( // // Occurrences the component already carries are replaced: a fileset is what the image // holds, and a scanner's guess at where it saw the package is not evidence of the same -// kind. A fileset that names files the image does not hold at any of them replaces -// nothing: what a donor recorded of the same component is the only way left to find its -// bytes where this image put them, and an empty list in its place would read as the files -// having been looked for and missed. +// kind. The one exception is a fileset naming files the image holds at none of them, over +// a component werf itself recorded occurrences for before: those digests are the only way +// left to find the bytes where this image put them, and an empty list over them would say +// the files are gone when they may only have moved. Over a component werf never recorded, +// the empty list is written, because there is nothing else that could show the files and +// "its own file list names files it does not hold" is what an image that installed a +// package and then deleted it looks like. // // Returns the number of components given occurrences. func RecordOccurrences(bom *cdx.BOM, filesets []Fileset) int { @@ -158,6 +161,36 @@ func canonicalName(ecosystem Ecosystem, name string) string { // content of its own to compare. const symlinkContext = "symlink" +const digestPrefix = "sha256:" + +// recordedOccurrences reads back what werf recorded of a component's files. An occurrence +// without a digest or the symlink marker came from somewhere else — a scanner noting where +// it saw a package, a document merged in — and says nothing about content; a component +// carrying none of werf's own is one nothing is known about, not one whose files were +// looked for and missed. +func recordedOccurrences(comp *cdx.Component) []Occurrence { + if comp.Evidence == nil || comp.Evidence.Occurrences == nil { + return nil + } + + recorded := *comp.Evidence.Occurrences + occurrences := make([]Occurrence, 0, len(recorded)) + for _, occurrence := range recorded { + switch { + case occurrence.AdditionalContext == symlinkContext: + occurrences = append(occurrences, Occurrence{Path: occurrence.Location, Symlink: true}) + case strings.HasPrefix(occurrence.AdditionalContext, digestPrefix): + occurrences = append(occurrences, Occurrence{Path: occurrence.Location, Digest: occurrence.AdditionalContext}) + } + } + + if len(recorded) > 0 && len(occurrences) == 0 { + return nil + } + + return occurrences +} + // setOccurrences records every file of every fileset of one component. npm installs a // package more than once under different paths, and each copy is a fileset of its own; // what the component ships is all of them. Reports whether anything was recorded. @@ -172,7 +205,7 @@ func setOccurrences(comp *cdx.Component, sets []Fileset) bool { } } - if len(contexts) == 0 { + if len(contexts) == 0 && recordedOccurrences(comp) != nil { return false } diff --git a/pkg/sbom/shippedscope/occurrences_test.go b/pkg/sbom/shippedscope/occurrences_test.go index 1fe1783771..75121038be 100644 --- a/pkg/sbom/shippedscope/occurrences_test.go +++ b/pkg/sbom/shippedscope/occurrences_test.go @@ -122,7 +122,7 @@ var _ = Describe("RecordOccurrences", func() { }), "the digests a donor recorded are the only way left to find the bytes this image moved") }) - It("says nothing of a component the image holds no declared file of and no donor described", func() { + It("says the files were looked for when the image holds no declared file of a component no donor described", func() { bom := bomOf("pkg:pypi/requests@2.32.3") recorded := RecordOccurrences(bom, []Fileset{{ @@ -132,8 +132,9 @@ var _ = Describe("RecordOccurrences", func() { DeclaresFiles: true, }}) - Expect(recorded).To(BeZero()) - Expect((*bom.Components)[0].Evidence).To(BeNil()) + Expect(recorded).To(Equal(1)) + Expect(occurrencesOf(bom, 0)).To(Equal([]cdx.EvidenceOccurrence{}), + "a package the image installed and then deleted looks exactly like this, and nothing else could show its files") }) It("records a component one of whose copies declares files", func() { @@ -217,16 +218,30 @@ var _ = Describe("RecordOccurrences", func() { })) }) - It("claims nothing of a component a fileset names but the image kept no file of", func() { + It("says the files were looked for when a fileset names a component but the image kept none", func() { bom := bomOf("pkg:generic/zlib@1.3.1") recorded := RecordOccurrences(bom, []Fileset{ fileset(EcosystemOSPM, "zlib", "1.3.1", nil, nil), }) - Expect(recorded).To(BeZero()) - Expect((*bom.Components)[0].Evidence).To(BeNil(), - "an empty list would read as the files having been looked for and missed, which an index that drops a file it cannot resolve would produce too") + Expect(recorded).To(Equal(1)) + Expect(occurrencesOf(bom, 0)).To(Equal([]cdx.EvidenceOccurrence{})) + }) + + It("keeps the occurrences werf recorded before over a fileset the image kept no file of, but replaces a scanner's", func() { + bom := bomOf("pkg:generic/zlib@1.3.1") + (*bom.Components)[0].Evidence = &cdx.Evidence{Occurrences: &[]cdx.EvidenceOccurrence{ + {Location: "/var/lib/dpkg/status"}, + }} + + recorded := RecordOccurrences(bom, []Fileset{ + fileset(EcosystemOSPM, "zlib", "1.3.1", nil, nil), + }) + + Expect(recorded).To(Equal(1)) + Expect(occurrencesOf(bom, 0)).To(Equal([]cdx.EvidenceOccurrence{}), + "where a scanner saw the package says nothing of its files, so there is nothing worth keeping over the image's own answer") }) It("leaves a component no fileset names unmarked, so nothing is known of its files", func() { From 28c65e3304855e4c7227c5186db9e20545902df2 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Tue, 20 Oct 2026 15:23:16 +0300 Subject: [PATCH 3/4] chore(sbom): bump the SBOM artifact format to 12 The verdict property and the changed occurrence semantics make every SBOM cached under format 11 stale; without the bump an image already built never gets a mark until its stage digest moves for an unrelated reason. Returning the constant to 11 is the way back: every SBOM is recomputed without the rule. Signed-off-by: Radmir Khurum --- pkg/build/sbom_step.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index d75e95866c..c4a4299ad4 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -553,7 +553,7 @@ func (step *sbomStep) recordDeclaredPackages(ctx context.Context, bom *cdx.BOM, return nil } -const sbomArtifactFormatVersion = "11" +const sbomArtifactFormatVersion = "12" // 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 From 2fe429e4340f1586c5618c699b75ac06a0405d93 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Tue, 20 Oct 2026 15:29:02 +0300 Subject: [PATCH 4/4] test(sbom): pin the os-pm branch of the partial-import scenario The importing image inherits python the same way it inherits requests, and holds neither; the pm that installed python wrote no file list, so it is left unjudged where requests is marked. The CollectFilesets godoc now says the index comes back with the filesets, so no caller re-reads the image for a file no fileset names. Signed-off-by: Radmir Khurum --- pkg/sbom/shippedscope/fileset.go | 4 +++- test/e2e/sbom/pip_test.go | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/sbom/shippedscope/fileset.go b/pkg/sbom/shippedscope/fileset.go index 2bcc388e9d..af562cdb31 100644 --- a/pkg/sbom/shippedscope/fileset.go +++ b/pkg/sbom/shippedscope/fileset.go @@ -49,7 +49,9 @@ type Filesets struct { } // CollectFilesets reads the image once and answers, for every component an ecosystem -// installed into it, which files are that component's. +// installed into it, which files are that component's. The index that read produced comes +// back with the filesets, never nil, so that a consumer needing a file no fileset names +// does not read the image again. // // Each ecosystem draws the boundary itself, and none of them draw it around a directory: // Python takes the paths its RECORD declares, JavaScript takes a package directory minus diff --git a/test/e2e/sbom/pip_test.go b/test/e2e/sbom/pip_test.go index 04e92873f6..ff0c9feb52 100644 --- a/test/e2e/sbom/pip_test.go +++ b/test/e2e/sbom/pip_test.go @@ -128,10 +128,19 @@ var _ = Describe("SBOM python-pip packages", Label("e2e", "sbom", "pip", "simple // The importing image takes one file of the donor and inherits the donor's whole BOM // with it, requests included, while none of the files requests consists of came along. - inherited := sbomtest.FindComponent(bomOf("app"), "requests", "2.32.3") + appBOM := bomOf("app") + inherited := sbomtest.FindComponent(appBOM, "requests", "2.32.3") Expect(inherited).NotTo(BeNil(), "expected requests@2.32.3 to be inherited into the BOM of the importing image") value, marked := shippedProperty(inherited) Expect(marked).To(BeTrue(), "requests is in the BOM of an image that carries none of its files, so it must be marked") Expect(value).To(Equal("false")) + + // python is inherited the same way and is not in the importing image either, but the pm + // that installed it into the donor wrote no file list, so nothing can show where its + // files are and it is left unjudged rather than marked. + python := sbomtest.FindComponent(appBOM, "python", "3.12.12") + Expect(python).NotTo(BeNil(), "expected python@3.12.12 to be inherited into the BOM of the importing image") + _, marked = shippedProperty(python) + Expect(marked).To(BeFalse(), "an os-pm package whose files no file list names is one nothing is known about") }) })