From 07d841e97ceb4acfa735f8f8f8033e4b44524731 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 13 Sep 2026 23:17:13 -0400 Subject: [PATCH 1/2] feat: serve image-scope SBOM evidence (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builder.SBOM dispatches on request scope. Image scope delegates to core's shared scanner, which binds each inventory to the digest it actually scanned and covers OS packages as well as application dependencies — what a module graph cannot describe. Source scope is unchanged, and an unset scope still answers source, so existing callers keep working. Go images are built by whoever executes the emitted recipe, so the agent holds no digest of its own. Asked for image scope with no subjects it reports a precondition failure rather than passing the module graph off as image coverage, claiming to be unimplemented, or declaring a no-image reason that would be untrue for a service that does ship an image. The image_sbom validation capability is deliberately not advertised yet: ciinputs.Required emits TASK_PHASE_IMAGE_SBOM, which ciinputs.Evaluate rejects as an invalid task key, so advertising it breaks GetEffectiveInputs for every phase. Filed as codefly-dev/core#501. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/builder/builder.go | 14 +++- recipe_test.go | 151 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/pkg/builder/builder.go b/pkg/builder/builder.go index ff0d846..d58a320 100644 --- a/pkg/builder/builder.go +++ b/pkg/builder/builder.go @@ -237,11 +237,19 @@ func (s *Builder) Audit(ctx context.Context, req *builderv0.AuditRequest) (*buil return s.Builder.AuditResponse(req, result.Findings, result.Outdated, result.Tool, result.Language) } -// SBOM is inherited by every Go specialization and inventories the exact -// GOWORK-disabled module graph selected by go.mod/go.sum. -func (s *Builder) SBOM(ctx context.Context, _ *builderv0.SBOMRequest) (*builderv0.SBOMResponse, error) { +// SBOM is inherited by every Go specialization. Source scope inventories the +// exact GOWORK-disabled module graph selected by go.mod/go.sum. Image scope +// inventories a shipped image instead — its OS packages included, which a +// module graph says nothing about. Go images are built by whoever executes the +// emitted recipe, so the digests to scan arrive as caller-supplied subjects; +// asked for image scope with none, the shared helper reports the precondition +// failure rather than passing the module graph off as image coverage. +func (s *Builder) SBOM(ctx context.Context, req *builderv0.SBOMRequest) (*builderv0.SBOMResponse, error) { defer s.Wool.Catch() ctx = s.Wool.Inject(ctx) + if req.GetScope() == builderv0.SBOMScope_SBOM_SCOPE_IMAGE { + return s.Builder.SBOMImages(ctx, req.GetSubjects(), sbom.SourceRegistry) + } result, err := sbom.Golang(ctx, s.Service.SourceLocation) if err != nil { return s.Builder.SBOMError(err) diff --git a/recipe_test.go b/recipe_test.go index 5cbc80b..c4edc5e 100644 --- a/recipe_test.go +++ b/recipe_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/codefly-dev/core/agents/services" + "github.com/codefly-dev/core/agents/services/sbom" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" @@ -337,3 +338,153 @@ func TestBuildRejectsMissingOrRelativeDestinationOverGRPC(t *testing.T) { } } } + +// emittedPlan drives Build the way the CLI does and returns the recipe plan the +// agent hands back — the only description of its images the agent owns. +func emittedPlan(t *testing.T) (*gobuilder.Builder, context.Context, *builderv0.DockerBuildPlan) { + t.Helper() + b, ctx := loadedBuilder(t) + resp, err := b.Build(ctx, &builderv0.BuildRequest{ + OutputDirectory: filepath.Join(t.TempDir(), "recipe"), + BuildContext: &builderv0.BuildContext{ + Kind: &builderv0.BuildContext_DockerBuildContext{ + DockerBuildContext: &builderv0.DockerBuildContext{DockerRepository: "registry.example.com"}, + }, + }, + }) + if err != nil { + t.Fatalf("Build: %v", err) + } + plan := resp.GetResult().GetDockerBuildPlan() + if plan == nil { + t.Fatalf("expected a plan, got state %v message %q", resp.GetState().GetState(), resp.GetState().GetMessage()) + } + return b, ctx, plan +} + +// TestImageSBOMRequiresCallerSuppliedSubjects drives the SBOM RPC under image +// scope with no subjects. This agent emits a recipe and never runs buildx, so it +// holds no digest to inventory: the honest answer is a precondition failure, not +// an unsupported operation and not a no-image claim, because the service really +// does ship an image. +func TestImageSBOMRequiresCallerSuppliedSubjects(t *testing.T) { + b, _ := loadedBuilder(t) + client := grpcBuilderClient(t, b) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE}) + if err != nil { + t.Fatalf("SBOM: %v", err) + } + if state := resp.GetState().GetState(); state != builderv0.SBOMStatus_ERROR { + t.Fatalf("state = %s, want ERROR: %s", state, resp.GetState().GetMessage()) + } + // An image failure reported without image scope reads as a source inventory, + // which hides the cause behind a scope complaint. + if scope := resp.GetScope(); scope != builderv0.SBOMScope_SBOM_SCOPE_IMAGE { + t.Errorf("scope = %s, want image", scope) + } + if code := resp.GetState().GetFailure().GetCode(); code != basev0.FailureCode_FAILURE_CODE_PRECONDITION_FAILED { + t.Errorf("failure code = %s, want precondition failed", code) + } + if reason := resp.GetNoImageReason(); reason != builderv0.NoImageReason_NO_IMAGE_REASON_UNSPECIFIED { + t.Errorf("no-image reason = %s, but this service ships an image", reason) + } + if images := resp.GetImages(); len(images) != 0 { + t.Errorf("failed image scope carries %d inventories", len(images)) + } +} + +// TestImageSBOMCoverageDerivesFromTheEmittedRecipe checks the agent's own plan +// through the shared conformance helper: every shipped platform is its own +// subject, and the subjects-required failure never passes as coverage. +func TestImageSBOMCoverageDerivesFromTheEmittedRecipe(t *testing.T) { + b, ctx, plan := emittedPlan(t) + + expected := sbom.ExpectedFromBuildPlan("myservice", plan) + if len(expected) != 2 { + t.Fatalf("expected one subject per shipped platform, got %d", len(expected)) + } + for i, platform := range []string{"linux/amd64", "linux/arm64"} { + if got := expected[i].GetPlatform(); got != platform { + t.Errorf("subject %d platform = %q, want %q", i, got, platform) + } + if got := expected[i].GetRole(); got != "app" { + t.Errorf("subject %d role = %q, want the recipe name", i, got) + } + if got := expected[i].GetService(); got != "myservice" { + t.Errorf("subject %d service = %q", i, got) + } + } + + unsatisfied, err := b.SBOM(ctx, &builderv0.SBOMRequest{Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE}) + if err != nil { + t.Fatalf("SBOM: %v", err) + } + validation := sbom.ValidateCoverage(expected, unsatisfied) + if validation == nil { + t.Fatal("a precondition failure passed coverage validation") + } + if !strings.Contains(validation.Error(), "does not build its own images") { + t.Errorf("coverage failure does not name its cause: %v", validation) + } +} + +// TestSourceInventoryIsNotImageCoverage proves the unchanged source path still +// answers an unset scope, and that its module graph cannot stand in for evidence +// about the shipped image. +func TestSourceInventoryIsNotImageCoverage(t *testing.T) { + b, ctx, plan := emittedPlan(t) + + resp, err := b.SBOM(ctx, &builderv0.SBOMRequest{}) + if err != nil { + t.Fatalf("SBOM: %v", err) + } + if state := resp.GetState().GetState(); state != builderv0.SBOMStatus_COMPLETE { + t.Fatalf("source SBOM state = %s: %s", state, resp.GetState().GetMessage()) + } + if scope := resp.GetScope(); scope != builderv0.SBOMScope_SBOM_SCOPE_SOURCE { + t.Errorf("unset scope = %s, want source", scope) + } + if sbom.ValidateCoverage(sbom.ExpectedFromBuildPlan("myservice", plan), resp) == nil { + t.Fatal("a module inventory passed as image coverage") + } +} + +// TestImageSBOMScanFailuresStayImageScoped proves a failed scan propagates as an +// image-scope error rather than partial or fabricated coverage. The reserved +// .invalid TLD resolves nowhere, so the scan fails the same way whether or not a +// scanner toolchain is installed. +func TestImageSBOMScanFailuresStayImageScoped(t *testing.T) { + b, ctx, _ := emittedPlan(t) + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + digest := "sha256:" + strings.Repeat("a", 64) + resp, err := b.SBOM(ctx, &builderv0.SBOMRequest{ + Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE, + Subjects: []*builderv0.ImageSubject{{ + Reference: "registry.invalid/codefly/myservice@" + digest, + Digest: digest, + Platform: "linux/amd64", + Role: "app", + Service: "myservice", + }}, + }) + if err != nil { + t.Fatalf("SBOM: %v", err) + } + if state := resp.GetState().GetState(); state != builderv0.SBOMStatus_ERROR { + t.Fatalf("unreachable image state = %s, want ERROR", state) + } + if scope := resp.GetScope(); scope != builderv0.SBOMScope_SBOM_SCOPE_IMAGE { + t.Errorf("scope = %s, want image", scope) + } + if images := resp.GetImages(); len(images) != 0 { + t.Errorf("failed scan carries %d inventories", len(images)) + } + if resp.GetState().GetMessage() == "" { + t.Error("failed scan reports no diagnostics") + } +} From 7f5ba4d22dd4120bf831e6f1f2f347206ca23c7e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Mon, 14 Sep 2026 09:42:20 -0400 Subject: [PATCH 2/2] fix: refuse image SBOM subjects that name no immutable digest (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image scope resolved a subject's reference through the registry whatever it named. A subject carrying only a tag is inventoried from whatever that tag serves when the scan runs, which need not be the image the build produced: `codefly ci build --image-sbom` without `--push` loads the image into the local daemon only, so the registry tag is absent, or worse is a previously pushed image that scans cleanly. Nothing downstream caught the substitution. Subjects derived from a build plan name a tag and carry an empty digest, so the shared helper's mismatch check and ValidateCoverage both have nothing to compare, and evidence for the wrong image would have been reported as coverage for the built one — the exact false-coverage claim this contract exists to prevent. The agent cannot tell a pushed tag from a stale one, so it now refuses any subject that is not pinned by digest, either in the digest field or in the reference. Failing loudly names the missing pin; scanning anyway would have certified the wrong bits silently. Consequence worth stating: callers deriving subjects from a build plan must resolve the digest first. That gap is core's ExpectedFromBuildPlan, which emits tag references with an empty digest. Also bumps the agent to 0.0.49. v0.0.48 is exactly current main, so without this there is no released version carrying the image-scope dispatch and no tag for service-go-grpc to pin. Co-Authored-By: Claude Opus 5 (1M context) --- agent.codefly.yaml | 2 +- pkg/builder/builder.go | 21 +++++++++++ recipe_test.go | 80 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/agent.codefly.yaml b/agent.codefly.yaml index 179283c..e4924c6 100644 --- a/agent.codefly.yaml +++ b/agent.codefly.yaml @@ -1,4 +1,4 @@ publisher: codefly.dev kind: codefly:service name: go -version: 0.0.48 +version: 0.0.49 diff --git a/pkg/builder/builder.go b/pkg/builder/builder.go index d58a320..ba6d25f 100644 --- a/pkg/builder/builder.go +++ b/pkg/builder/builder.go @@ -248,6 +248,9 @@ func (s *Builder) SBOM(ctx context.Context, req *builderv0.SBOMRequest) (*builde defer s.Wool.Catch() ctx = s.Wool.Inject(ctx) if req.GetScope() == builderv0.SBOMScope_SBOM_SCOPE_IMAGE { + if subject := unpinnedImageSubject(req.GetSubjects()); subject != nil { + return s.Builder.SBOMImageError(fmt.Errorf("image subject %q names no immutable digest: a tag resolves to whatever the registry serves when the scan runs, which need not be the image this build produced; supply the digest the build resolved", subject.GetReference())) + } return s.Builder.SBOMImages(ctx, req.GetSubjects(), sbom.SourceRegistry) } result, err := sbom.Golang(ctx, s.Service.SourceLocation) @@ -257,6 +260,24 @@ func (s *Builder) SBOM(ctx context.Context, req *builderv0.SBOMRequest) (*builde return s.Builder.SBOMResponse(result.Bom, result.Tool, result.Language, result.SHA256) } +// unpinnedImageSubject returns the first subject that names no immutable image. +// Evidence is only ever compared against a digest the caller already holds, so a +// subject carrying nothing but a tag would be inventoried from whatever the +// registry serves at scan time and reported as coverage for the built image +// without anything detecting the substitution. +func unpinnedImageSubject(subjects []*builderv0.ImageSubject) *builderv0.ImageSubject { + for _, subject := range subjects { + if strings.HasPrefix(subject.GetDigest(), "sha256:") { + continue + } + if _, reference, pinned := strings.Cut(subject.GetReference(), "@"); pinned && strings.HasPrefix(reference, "sha256:") { + continue + } + return subject + } + return nil +} + // Package emits portable Go binaries and release-bound CycloneDX evidence. // The operation is plugin-owned and local-first; agent build, CI, and future // editor/Mind consumers all call this same typed RPC. diff --git a/recipe_test.go b/recipe_test.go index c4edc5e..ad7b1a7 100644 --- a/recipe_test.go +++ b/recipe_test.go @@ -452,20 +452,84 @@ func TestSourceInventoryIsNotImageCoverage(t *testing.T) { } } +// TestImageSBOMRefusesUnpinnedSubjects covers the substitution this agent cannot +// otherwise detect. A subject naming only a tag is inventoried from whatever the +// registry serves when the scan runs — for a build loaded locally and never +// pushed, that is a different image, or a previously pushed one. Nothing +// downstream catches it: a tag-derived subject carries no digest, so neither the +// shared helper's mismatch check nor ValidateCoverage has anything to compare, +// and a stale inventory would be reported as coverage for the built image. +func TestImageSBOMRefusesUnpinnedSubjects(t *testing.T) { + b, ctx := loadedBuilder(t) + // No scanner toolchain: a refusal must come from the missing digest, not + // from a scan that was attempted and happened to fail. + t.Setenv("PATH", t.TempDir()) + + reference := "registry.example.com/codefly/myservice:1.0.0" + resp, err := b.SBOM(ctx, &builderv0.SBOMRequest{ + Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE, + Subjects: []*builderv0.ImageSubject{{ + Reference: reference, + Platform: "linux/amd64", + Role: "app", + Service: "myservice", + }}, + }) + if err != nil { + t.Fatalf("SBOM: %v", err) + } + if state := resp.GetState().GetState(); state != builderv0.SBOMStatus_ERROR { + t.Fatalf("unpinned subject state = %s, want ERROR", state) + } + if scope := resp.GetScope(); scope != builderv0.SBOMScope_SBOM_SCOPE_IMAGE { + t.Errorf("scope = %s, want image", scope) + } + if images := resp.GetImages(); len(images) != 0 { + t.Errorf("refused request carries %d inventories", len(images)) + } + message := resp.GetState().GetMessage() + if !strings.Contains(message, "immutable digest") || !strings.Contains(message, reference) { + t.Errorf("refusal does not name the unpinned subject and why: %q", message) + } +} + +// TestImageSBOMAcceptsSubjectsPinnedByReference proves the pin check reads the +// reference as well as the digest field, so a caller that pins the reference +// itself reaches the scanner rather than being refused. +func TestImageSBOMAcceptsSubjectsPinnedByReference(t *testing.T) { + b, ctx := loadedBuilder(t) + t.Setenv("PATH", t.TempDir()) + + resp, err := b.SBOM(ctx, &builderv0.SBOMRequest{ + Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE, + Subjects: []*builderv0.ImageSubject{{ + Reference: "registry.example.com/codefly/myservice@sha256:" + strings.Repeat("a", 64), + Platform: "linux/amd64", + Role: "app", + Service: "myservice", + }}, + }) + if err != nil { + t.Fatalf("SBOM: %v", err) + } + if message := resp.GetState().GetMessage(); strings.Contains(message, "immutable digest") { + t.Errorf("reference-pinned subject was refused as unpinned: %q", message) + } +} + // TestImageSBOMScanFailuresStayImageScoped proves a failed scan propagates as an -// image-scope error rather than partial or fabricated coverage. The reserved -// .invalid TLD resolves nowhere, so the scan fails the same way whether or not a -// scanner toolchain is installed. +// image-scope error rather than partial or fabricated coverage. The scanner +// toolchain is removed from PATH so the failure is the same one in every +// environment, with no network lookup to depend on. func TestImageSBOMScanFailuresStayImageScoped(t *testing.T) { - b, ctx, _ := emittedPlan(t) - ctx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() + b, ctx := loadedBuilder(t) + t.Setenv("PATH", t.TempDir()) digest := "sha256:" + strings.Repeat("a", 64) resp, err := b.SBOM(ctx, &builderv0.SBOMRequest{ Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE, Subjects: []*builderv0.ImageSubject{{ - Reference: "registry.invalid/codefly/myservice@" + digest, + Reference: "registry.example.com/codefly/myservice@" + digest, Digest: digest, Platform: "linux/amd64", Role: "app", @@ -476,7 +540,7 @@ func TestImageSBOMScanFailuresStayImageScoped(t *testing.T) { t.Fatalf("SBOM: %v", err) } if state := resp.GetState().GetState(); state != builderv0.SBOMStatus_ERROR { - t.Fatalf("unreachable image state = %s, want ERROR", state) + t.Fatalf("unscannable image state = %s, want ERROR", state) } if scope := resp.GetScope(); scope != builderv0.SBOMScope_SBOM_SCOPE_IMAGE { t.Errorf("scope = %s, want image", scope)