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 ff0d846..ba6d25f 100644 --- a/pkg/builder/builder.go +++ b/pkg/builder/builder.go @@ -237,11 +237,22 @@ 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 { + 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) if err != nil { return s.Builder.SBOMError(err) @@ -249,6 +260,24 @@ func (s *Builder) SBOM(ctx context.Context, _ *builderv0.SBOMRequest) (*builderv 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 5cbc80b..ad7b1a7 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,217 @@ 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") + } +} + +// 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 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 := 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.example.com/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("unscannable 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") + } +}