From a10ccb5607ace4c9cc0b9fa7d9b963ca29327fde Mon Sep 17 00:00:00 2001 From: Alexandr Zaytsev Date: Thu, 2 Jul 2026 18:41:43 +0300 Subject: [PATCH 1/4] fix(sbom): prevent lack of SBOM completeness in context of trusted builder images Extract builder image classification into dedicated functions in sbom_builder.go. When SBOM pull fails for a trusted builder image, now differentiate between golang builders (emit deprecation warning), alpine builders (pass through), and other builders (hard error requiring SBOM). Signed-off-by: Alexandr Zaytsev --- pkg/build/sbom_builder.go | 25 ++++++++ pkg/build/sbom_builder_test.go | 107 +++++++++++++++++++++++++++++++++ pkg/build/sbom_step.go | 19 +++--- pkg/build/sbom_step_test.go | 51 +++++++++++++++- 4 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 pkg/build/sbom_builder.go create mode 100644 pkg/build/sbom_builder_test.go diff --git a/pkg/build/sbom_builder.go b/pkg/build/sbom_builder.go new file mode 100644 index 0000000000..d9fd4d2d9b --- /dev/null +++ b/pkg/build/sbom_builder.go @@ -0,0 +1,25 @@ +package build + +import ( + "fmt" + "strings" + + "github.com/werf/werf/v2/pkg/image" +) + +const containerFactoryRegistry = "registry.deckhouse.io/container-factory" + +func isGolangBuilderImage(name string) bool { + return strings.HasPrefix(name, fmt.Sprintf("%s/builder/golang", containerFactoryRegistry)) +} + +func isAlpineBuilderImage(name string) bool { + return strings.HasPrefix(name, fmt.Sprintf("%s/builder/alpine", containerFactoryRegistry)) +} + +func isTrustedBuilderImage(labels map[string]string) bool { + if labels == nil { + return false + } + return labels[image.DeckhouseInternalBuilderLabel] == "true" +} diff --git a/pkg/build/sbom_builder_test.go b/pkg/build/sbom_builder_test.go new file mode 100644 index 0000000000..e870c6bfab --- /dev/null +++ b/pkg/build/sbom_builder_test.go @@ -0,0 +1,107 @@ +package build + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + werfImage "github.com/werf/werf/v2/pkg/image" +) + +var _ = Describe("Builder image classification", func() { + Describe("isGolangBuilderImage()", func() { + DescribeTable("should detect golang builder images from container-factory registry", + func(name string, expected bool) { + Expect(isGolangBuilderImage(name)).To(Equal(expected)) + }, + // Golang images: must return true + Entry("golang-alpine with tag", "registry.deckhouse.io/container-factory/builder/golang-alpine:1.25", true), + Entry("golang-alt with tag", "registry.deckhouse.io/container-factory/builder/golang-alt:1.25", true), + Entry("golang-debian with tag", "registry.deckhouse.io/container-factory/builder/golang-debian:1.25", true), + Entry("golang-wolfi with tag", "registry.deckhouse.io/container-factory/builder/golang-wolfi:1.25", true), + Entry("golang plain with tag", "registry.deckhouse.io/container-factory/builder/golang:1.26", true), + Entry("golang-artifact with tag", "registry.deckhouse.io/container-factory/builder/golang-artifact:1.26", true), + Entry("golang with digest", "registry.deckhouse.io/container-factory/builder/golang@sha256:abc123", true), + Entry("golang without tag", "registry.deckhouse.io/container-factory/builder/golang", true), + + // Alpine images: must return false (not golang) + Entry("alpine (not golang)", "registry.deckhouse.io/container-factory/builder/alpine:3.22", false), + Entry("alpine-svace (not golang)", "registry.deckhouse.io/container-factory/builder/alpine-svace:3.22", false), + Entry("alpine with digest (not golang)", "registry.deckhouse.io/container-factory/builder/alpine@sha256:abc123", false), + + // Node images: must return false + Entry("node-alpine (not golang)", "registry.deckhouse.io/container-factory/builder/node-alpine:22.22", false), + + // Other builder images from container-factory: must return false + Entry("scratch builder", "registry.deckhouse.io/container-factory/builder/scratch", false), + Entry("wolfi builder", "registry.deckhouse.io/container-factory/builder/wolfi", false), + Entry("distroless builder", "registry.deckhouse.io/container-factory/builder/distroless", false), + + // Images from other namespaces: must return false + Entry("werf registry golang image", "registry.werf.io/base/golang:1.12-alpine3.9", false), + Entry("docker.io image", "docker.io/namespace/repo:builder-tag", false), + Entry("docker hub library", "docker.io/library/golang:1.21", false), + + // Edge cases + Entry("empty string", "", false), + Entry("just a path without registry", "builder/golang:1.26", false), + Entry("path with golang substring but different registry", "example.com/container-factory/builder/golang:1.0", false), + ) + + It("should reject image where golang appears as substring in the registry name", func() { + Expect(isGolangBuilderImage("registry.deckhouse.io/container-factory-builder-golang/other:1.0")).To(BeFalse()) + }) + }) + + Describe("isAlpineBuilderImage()", func() { + DescribeTable("should detect alpine builder images from container-factory registry", + func(name string, expected bool) { + Expect(isAlpineBuilderImage(name)).To(Equal(expected)) + }, + // Alpine images: must return true + Entry("alpine with tag", "registry.deckhouse.io/container-factory/builder/alpine:3.22", true), + Entry("alpine-svace with tag", "registry.deckhouse.io/container-factory/builder/alpine-svace:3.22", true), + Entry("alpine with digest", "registry.deckhouse.io/container-factory/builder/alpine@sha256:abc123", true), + Entry("alpine without tag", "registry.deckhouse.io/container-factory/builder/alpine", true), + + // Golang-alpine: must return false (starts with builder/golang, not builder/alpine) + Entry("golang-alpine (not alpine builder)", "registry.deckhouse.io/container-factory/builder/golang-alpine:1.25", false), + + // Node-alpine: must return false (starts with builder/node, not builder/alpine) + Entry("node-alpine (not alpine builder)", "registry.deckhouse.io/container-factory/builder/node-alpine:22.22", false), + + // Other golang images: must return false + Entry("golang (not alpine)", "registry.deckhouse.io/container-factory/builder/golang:1.26", false), + Entry("golang-debian (not alpine)", "registry.deckhouse.io/container-factory/builder/golang-debian:1.26", false), + + // Other builder images from container-factory: must return false + Entry("scratch builder", "registry.deckhouse.io/container-factory/builder/scratch", false), + Entry("wolfi builder", "registry.deckhouse.io/container-factory/builder/wolfi", false), + Entry("distroless builder", "registry.deckhouse.io/container-factory/builder/distroless", false), + + // Images from other namespaces: must return false + Entry("werf registry alpine image", "registry.werf.io/base/alpine:3.18", false), + Entry("docker.io image", "docker.io/namespace/repo:alpine", false), + + // Edge cases + Entry("empty string", "", false), + Entry("just a path without registry", "builder/alpine:3.18", false), + Entry("alpine under different registry", "example.com/container-factory/builder/alpine:1.0", false), + ) + }) + + Describe("isTrustedBuilderImage()", func() { + DescribeTable("should detect trusted builder images by label", + func(labels map[string]string, expected bool) { + Expect(isTrustedBuilderImage(labels)).To(Equal(expected)) + }, + Entry("nil labels", nil, false), + Entry("empty labels", map[string]string{}, false), + Entry("label set to false", map[string]string{werfImage.DeckhouseInternalBuilderLabel: "false"}, false), + Entry("label set to true", map[string]string{werfImage.DeckhouseInternalBuilderLabel: "true"}, true), + Entry("case-sensitive label mismatch", map[string]string{"io.deckhouse.internal.builder": "True"}, false), + Entry("other labels without builder", map[string]string{"foo": "bar", "baz": "qux"}, false), + Entry("other labels with builder true", map[string]string{"foo": "bar", werfImage.DeckhouseInternalBuilderLabel: "true", "baz": "qux"}, true), + Entry("value is non-empty but not true", map[string]string{werfImage.DeckhouseInternalBuilderLabel: "yes"}, false), + ) + }) +}) diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index ff16fa35ef..7d910873b4 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -20,6 +20,7 @@ import ( osPm "github.com/werf/werf/v2/pkg/sbom/packages/os_pm" "github.com/werf/werf/v2/pkg/sbom/scanner" "github.com/werf/werf/v2/pkg/storage" + "github.com/werf/werf/v2/pkg/werf/global_warnings" ) //go:generate mockgen -source sbom_step.go -package mock -destination ../../test/mock/bom_patcher.go -mock_names BOMPatcherInterface=MockBOMPatcher @@ -165,7 +166,16 @@ func (step *sbomStep) GetImageBOM(ctx context.Context, imageName string, imageIn bom, err := step.pullImageSbom(ctx, imageName, imageInfo) if err != nil { if isTrustedBuilderImage(imageInfo.Labels) { - return nil, ErrSbomNotRequired + switch { + case isGolangBuilderImage(imageInfo.Name): + global_warnings.GlobalWarningLn(ctx, + fmt.Sprintf("The builder image %q is DEPRECATED and it WILL CAUSE AN ERROR in the future. Plan your migration to an up-to-date builder image.", imageInfo.Name)) + return nil, ErrSbomNotRequired + case isAlpineBuilderImage(imageInfo.Name): + return nil, ErrSbomNotRequired + default: + return nil, fmt.Errorf("the base image %q must have an SBOM artifact attached; the image is a builder image but SBOM is required; %w", imageInfo.Name, err) + } } return nil, fmt.Errorf("the base image %q must either have the label %q set to \"true\" or have an SBOM artifact attached; to generate an SBOM for the base image, rebuild it with SBOM generation enabled: %w", imageInfo.Name, image.DeckhouseInternalBuilderLabel, err) } @@ -192,13 +202,6 @@ func (step *sbomStep) pullImageSbom(ctx context.Context, imageName string, image return bom, nil } -func isTrustedBuilderImage(labels map[string]string) bool { - if labels == nil { - return false - } - return labels[image.DeckhouseInternalBuilderLabel] == "true" -} - func (step *sbomStep) prepareGostComponents(ctx context.Context, mergeOpts *cyclonedxutil.MergeOpts) error { if !mergeOpts.Gost.AttackSurface.IsUndefined() || !mergeOpts.Gost.SecurityFunction.IsUndefined() { logboek.Context(ctx).Default().LogF("Warning: GOST SBOM integration is experimental and its behavior may change in the future\n") diff --git a/pkg/build/sbom_step_test.go b/pkg/build/sbom_step_test.go index c546ea47ea..87c4b17c16 100644 --- a/pkg/build/sbom_step_test.go +++ b/pkg/build/sbom_step_test.go @@ -105,7 +105,7 @@ var _ = Describe("SbomStep", func() { }) Describe("GetImageBOM() with trusted builder image", func() { - It("should return ErrSbomNotRequired for builder image regardless of digest availability", func(ctx SpecContext) { + It("should return hard error for builder image from different namespace", func(ctx SpecContext) { step := &sbomStep{} imageInfo := &werfImage.Info{ @@ -117,9 +117,58 @@ var _ = Describe("SbomStep", func() { } _, err := step.GetImageBOM(ctx, "builder-image", imageInfo) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrSbomNotRequired)).To(BeFalse()) + Expect(err.Error()).To(ContainSubstring("the image is a builder image but SBOM is required")) + }) + + It("should return ErrSbomNotRequired for golang builder image from container-factory", func(ctx SpecContext) { + step := &sbomStep{} + + imageInfo := &werfImage.Info{ + Name: "registry.deckhouse.io/container-factory/builder/golang-alpine:1.25", + Repository: "registry.deckhouse.io/container-factory/builder/golang-alpine", + Labels: map[string]string{ + werfImage.DeckhouseInternalBuilderLabel: "true", + }, + } + + _, err := step.GetImageBOM(logging.WithLogger(ctx), "builder-image", imageInfo) Expect(err).To(MatchError(ErrSbomNotRequired)) }) + It("should return ErrSbomNotRequired for alpine builder image from container-factory", func(ctx SpecContext) { + step := &sbomStep{} + + imageInfo := &werfImage.Info{ + Name: "registry.deckhouse.io/container-factory/builder/alpine:3.22", + Repository: "registry.deckhouse.io/container-factory/builder/alpine", + Labels: map[string]string{ + werfImage.DeckhouseInternalBuilderLabel: "true", + }, + } + + _, err := step.GetImageBOM(ctx, "builder-image", imageInfo) + Expect(err).To(MatchError(ErrSbomNotRequired)) + }) + + It("should return hard error for other builder image from container-factory", func(ctx SpecContext) { + step := &sbomStep{} + + imageInfo := &werfImage.Info{ + Name: "registry.deckhouse.io/container-factory/builder/scratch", + Repository: "registry.deckhouse.io/container-factory/builder/scratch", + Labels: map[string]string{ + werfImage.DeckhouseInternalBuilderLabel: "true", + }, + } + + _, err := step.GetImageBOM(ctx, "builder-image", imageInfo) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrSbomNotRequired)).To(BeFalse()) + Expect(err.Error()).To(ContainSubstring("the image is a builder image but SBOM is required")) + }) + It("should return actionable error for non-builder image when SBOM pull fails", func(ctx SpecContext) { step := &sbomStep{} From ee6023b139465d82f7f5648d717b8422ce9c988e Mon Sep 17 00:00:00 2001 From: Alexandr Zaytsev Date: Tue, 7 Jul 2026 13:49:31 +0300 Subject: [PATCH 2/4] chore(main): resolve conflicts after rebasing with main --- pkg/build/sbom_step.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index 7d910873b4..d9988f7edd 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "strings" cdx "github.com/CycloneDX/cyclonedx-go" @@ -174,6 +175,9 @@ func (step *sbomStep) GetImageBOM(ctx context.Context, imageName string, imageIn case isAlpineBuilderImage(imageInfo.Name): return nil, ErrSbomNotRequired default: + if os.Getenv("WERF_E2E_ALLOW_LOCAL_BUILDER_IMAGES") == "true" { + return nil, ErrSbomNotRequired + } return nil, fmt.Errorf("the base image %q must have an SBOM artifact attached; the image is a builder image but SBOM is required; %w", imageInfo.Name, err) } } From baf78fa098ab42e8aa14912af8163a6bf1c4abaa Mon Sep 17 00:00:00 2001 From: Alexandr Zaytsev Date: Tue, 7 Jul 2026 14:30:46 +0300 Subject: [PATCH 3/4] test(sbom): allow local builder images in e2e tests Signed-off-by: Alexandr Zaytsev --- test/e2e/sbom/common_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/e2e/sbom/common_test.go b/test/e2e/sbom/common_test.go index 9fe7f1b47f..645313effc 100644 --- a/test/e2e/sbom/common_test.go +++ b/test/e2e/sbom/common_test.go @@ -43,5 +43,8 @@ func buildTrustedBuilderBase(ctx SpecContext, testRepoPath, refSlug string) []st builderBaseRef := fmt.Sprintf("%s/%s:test", suite_init.TestRegistry(), refSlug) utils.RunSucceedCommand(ctx, testRepoPath, "docker", "build", "-t", builderBaseRef, "-f", "Dockerfile.builder-base", ".") utils.RunSucceedCommand(ctx, testRepoPath, "docker", "push", builderBaseRef) - return []string{fmt.Sprintf("BUILDER_BASE_IMAGE=%s", builderBaseRef)} + return []string{ + fmt.Sprintf("BUILDER_BASE_IMAGE=%s", builderBaseRef), + "WERF_E2E_ALLOW_LOCAL_BUILDER_IMAGES=true", + } } From 9f5c49254613634a1c7abc538d96d1b0e89a546b Mon Sep 17 00:00:00 2001 From: Alexandr Zaytsev Date: Tue, 7 Jul 2026 16:44:02 +0300 Subject: [PATCH 4/4] fix(sbom): show deprecation warning for alpine builder images Alpine builder images were silently skipped without a deprecation warning. Now they trigger the same warning as golang builder images to encourage migration. Signed-off-by: Alexandr Zaytsev --- pkg/build/sbom_step.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_step.go index d9988f7edd..39a90fca8e 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_step.go @@ -168,12 +168,10 @@ func (step *sbomStep) GetImageBOM(ctx context.Context, imageName string, imageIn if err != nil { if isTrustedBuilderImage(imageInfo.Labels) { switch { - case isGolangBuilderImage(imageInfo.Name): + case isGolangBuilderImage(imageInfo.Name), isAlpineBuilderImage(imageInfo.Name): global_warnings.GlobalWarningLn(ctx, fmt.Sprintf("The builder image %q is DEPRECATED and it WILL CAUSE AN ERROR in the future. Plan your migration to an up-to-date builder image.", imageInfo.Name)) return nil, ErrSbomNotRequired - case isAlpineBuilderImage(imageInfo.Name): - return nil, ErrSbomNotRequired default: if os.Getenv("WERF_E2E_ALLOW_LOCAL_BUILDER_IMAGES") == "true" { return nil, ErrSbomNotRequired