Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent.codefly.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
publisher: codefly.dev
kind: codefly:service
name: go
version: 0.0.48
version: 0.0.49
35 changes: 32 additions & 3 deletions pkg/builder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,18 +237,47 @@ 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)
}
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.
Expand Down
215 changes: 215 additions & 0 deletions recipe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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")
}
}
Loading