Skip to content

Commit eeb6d52

Browse files
feat: inventory the final runtime image through the shared SBOM contract (#35)
The inherited Python SBOM reads uv's lockfile: it describes resolved dependencies and proves nothing about the alpine base layer the service actually ships. Builder.SBOM now serves image scope as well, delegating to core's shared scanner so no scanner is duplicated here. This agent emits a build recipe and never runs buildx, so the image digest exists only on the caller's side. Image scope with explicit subjects serves evidence; with empty subjects core answers PRECONDITION_FAILED, which is the truthful answer for a service that does ship an image — UNSUPPORTED would deny an implementation that exists and a no-image reason would deny the image. CI installs syft pinned to core's SyftVersion so the end-to-end scan runs rather than skips: scanning a --load image goes through the Docker daemon and core's managed scanner container has no socket to reach it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 39395b6 commit eeb6d52

7 files changed

Lines changed: 264 additions & 2 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@ jobs:
1616
with:
1717
# The Python runtime tests drive `uv` on the host (dependency sync,
1818
# pytest); it isn't on the runner by default.
19+
#
20+
# syft is what makes the image SBOM test run rather than skip: scanning
21+
# a --load image that was never pushed goes through the Docker daemon,
22+
# and core's managed scanner container has no socket to reach it. The
23+
# version matches core's own SyftVersion pin.
1924
setup-run: |
2025
curl -LsSf https://astral.sh/uv/install.sh | sh
26+
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
27+
| sh -s -- -b "$HOME/.local/bin" v1.48.0
2128
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
2229
2330
# The runner lifecycle (Init / Start / Stop / Destroy) is concurrent gRPC

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ __pycache__
88
dist/
99
testdata/container*
1010
testdata/native*
11+
testdata/recipe*
1112
service-python-fastapi
1213
go.work
1314
go.work.sum

‎agent.codefly.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
publisher: codefly.dev
22
kind: codefly:service
33
name: python-fastapi
4-
version: 0.0.99
4+
version: 0.0.100

‎builder.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/codefly-dev/core/agents/communicate"
1010
"github.com/codefly-dev/core/agents/services"
11+
"github.com/codefly-dev/core/agents/services/sbom"
1112
"github.com/codefly-dev/core/agents/services/upgrade"
1213
"github.com/codefly-dev/core/companions/proto"
1314
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
@@ -197,6 +198,24 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
197198
return s.Base.Builder.SingleImageBuildResponse(req, s.DockerImage(dockerRequest).FullName(), emitted)
198199
}
199200

201+
// SBOM serves both evidence scopes. Source inventory stays with the generic
202+
// Python builder, which reads uv's lockfile; it describes the resolved
203+
// dependency set and says nothing about the OS packages of a shipped image.
204+
//
205+
// Image scope goes to core's shared scanner. This agent emits a build recipe
206+
// and never runs buildx, so the digests only exist on the caller's side and
207+
// arrive as explicit subjects. SBOMImages answers empty subjects with a
208+
// precondition failure of its own: the service does ship an image, so
209+
// neither UNSUPPORTED nor a no-image reason would be true.
210+
func (s *Builder) SBOM(ctx context.Context, req *builderv0.SBOMRequest) (*builderv0.SBOMResponse, error) {
211+
defer s.Wool.Catch()
212+
if req.GetScope() != builderv0.SBOMScope_SBOM_SCOPE_IMAGE {
213+
return s.Builder.SBOM(ctx, req)
214+
}
215+
ctx = s.Wool.Inject(ctx)
216+
return s.Base.Builder.SBOMImages(ctx, req.GetSubjects(), sbom.SourceRegistry)
217+
}
218+
200219
// Upgrade bumps Python dependencies in requirements.txt (pip list
201220
// --outdated + rewrite + pip install --upgrade). --major allows major
202221
// version jumps; --dry-run skips the write.

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.27.0
44

55
require (
66
github.com/codefly-dev/core v0.3.29
7-
github.com/codefly-dev/service-python v0.0.61
7+
github.com/codefly-dev/service-python v0.0.62
88
github.com/stretchr/testify v1.12.1
99
google.golang.org/grpc v1.83.1
1010
gopkg.in/yaml.v3 v3.0.1

‎go.sum‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1
4949
github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg=
5050
github.com/codefly-dev/service-python v0.0.61 h1:kiS7nC4vqXNJ4tajpwj42uIjUxIMUDcLuJSOlt0vFuw=
5151
github.com/codefly-dev/service-python v0.0.61/go.mod h1:GHxhihx2UsWWOoXcFBKBfD7kz5XjyI6x0sQV5VRjQ5s=
52+
github.com/codefly-dev/service-python v0.0.62 h1:quru4JzJi8v5nhiYTzXjl2SswXIasyeYq/iaM6TvN4k=
53+
github.com/codefly-dev/service-python v0.0.62/go.mod h1:LEJP0KA7BRALgQU8YLBwwVxXls5WwQLQ8esbMDG6IOY=
5254
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
5355
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
5456
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=

‎image_sbom_test.go‎

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/codefly-dev/core/agents/services/sbom"
13+
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
14+
agentv0 "github.com/codefly-dev/core/generated/go/codefly/services/agent/v0"
15+
builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
// imageSBOMRequest asks for image scope, optionally naming subjects.
20+
func imageSBOMRequest(subjects ...*builderv0.ImageSubject) *builderv0.SBOMRequest {
21+
return &builderv0.SBOMRequest{
22+
Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE,
23+
Subjects: subjects,
24+
}
25+
}
26+
27+
// emitRecipePlan runs a recipe build and returns the emitted plan together with
28+
// the directory the Dockerfile was written to.
29+
func emitRecipePlan(t *testing.T, builder *Builder) (*builderv0.DockerBuildPlan, string) {
30+
t.Helper()
31+
outputDir := t.TempDir()
32+
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
33+
defer cancel()
34+
resp, err := builder.Build(ctx, recipeBuildRequest(outputDir))
35+
require.NoError(t, err)
36+
require.Equal(t, builderv0.BuildStatus_SUCCESS, resp.GetState().GetState(), resp.GetState().GetMessage())
37+
plan := resp.GetResult().GetDockerBuildPlan()
38+
require.NotNil(t, plan)
39+
return plan, outputDir
40+
}
41+
42+
// TestImageSBOMWithoutSubjectsIsPreconditionFailure pins the answer this agent
43+
// owes when it is asked to enumerate images it never builds. The CLI executes
44+
// the emitted recipe, so the digest exists only on its side. Reporting
45+
// UNSUPPORTED would deny an implementation that exists, and a no-image reason
46+
// would deny an image the service really ships — both are the false coverage
47+
// claim the contract exists to prevent.
48+
func TestImageSBOMWithoutSubjectsIsPreconditionFailure(t *testing.T) {
49+
builder, _ := createdBuilder(t)
50+
51+
resp, err := builder.SBOM(t.Context(), imageSBOMRequest())
52+
require.NoError(t, err)
53+
54+
require.Equal(t, builderv0.SBOMStatus_ERROR, resp.GetState().GetState())
55+
require.Equal(t, basev0.FailureCode_FAILURE_CODE_PRECONDITION_FAILED, resp.GetState().GetFailure().GetCode())
56+
require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_IMAGE, resp.GetScope())
57+
require.Equal(t, builderv0.NoImageReason_NO_IMAGE_REASON_UNSPECIFIED, resp.GetNoImageReason())
58+
require.Empty(t, resp.GetImages())
59+
}
60+
61+
// TestImageSBOMExpectationsMatchTheEmittedRecipe verifies the specialization's
62+
// actual build-recipe path against the coverage helper the fleet grades every
63+
// agent with: one subject per shipped platform, carrying the recipe's role and
64+
// final image reference.
65+
func TestImageSBOMExpectationsMatchTheEmittedRecipe(t *testing.T) {
66+
builder, _ := createdBuilder(t)
67+
plan, _ := emitRecipePlan(t, builder)
68+
69+
expected := sbom.ExpectedFromBuildPlan(builder.Identity.Name, plan)
70+
71+
require.Len(t, expected, 2)
72+
var platforms []string
73+
for _, subject := range expected {
74+
platforms = append(platforms, subject.GetPlatform())
75+
require.Equal(t, "app", subject.GetRole())
76+
require.Equal(t, builder.Identity.Name, subject.GetService())
77+
require.NotEmpty(t, subject.GetReference())
78+
}
79+
require.ElementsMatch(t, []string{"linux/amd64", "linux/arm64"}, platforms)
80+
}
81+
82+
// TestImageSBOMCoverageRejectsFalseClaims is the release gate: every shape that
83+
// could pass off "no evidence" as complete image coverage has to fail
84+
// ValidateCoverage against the service's own declared build.
85+
func TestImageSBOMCoverageRejectsFalseClaims(t *testing.T) {
86+
builder, _ := createdBuilder(t)
87+
plan, _ := emitRecipePlan(t, builder)
88+
expected := sbom.ExpectedFromBuildPlan(builder.Identity.Name, plan)
89+
90+
t.Run("no-image reason contradicting the declared build", func(t *testing.T) {
91+
resp, err := builder.Base.Builder.SBOMNoImage(builderv0.NoImageReason_NO_IMAGE_REASON_EXTERNALLY_MANAGED, "vendor image")
92+
require.NoError(t, err)
93+
require.Error(t, sbom.ValidateCoverage(expected, resp))
94+
})
95+
96+
t.Run("source inventory is not image coverage", func(t *testing.T) {
97+
resp := &builderv0.SBOMResponse{
98+
State: &builderv0.SBOMStatus{State: builderv0.SBOMStatus_COMPLETE},
99+
Scope: builderv0.SBOMScope_SBOM_SCOPE_SOURCE,
100+
}
101+
require.Error(t, sbom.ValidateCoverage(expected, resp))
102+
})
103+
104+
t.Run("precondition failure never reads as coverage", func(t *testing.T) {
105+
resp, err := builder.SBOM(t.Context(), imageSBOMRequest())
106+
require.NoError(t, err)
107+
require.Error(t, sbom.ValidateCoverage(expected, resp))
108+
})
109+
110+
t.Run("one platform does not cover a multi-architecture image", func(t *testing.T) {
111+
covered := expected[0]
112+
resp, err := builder.Base.Builder.SBOMImageResponse([]*builderv0.ImageSBOM{{
113+
Digest: "sha256:" + strings.Repeat("a", 64),
114+
Platform: covered.GetPlatform(),
115+
Subjects: []*builderv0.ImageSubject{covered},
116+
Bom: &agentv0.Bom{Components: []*agentv0.Component{{BomRef: "pkg:apk/alpine/musl@1.2.5"}}},
117+
Sha256: strings.Repeat("b", 64),
118+
}})
119+
require.NoError(t, err)
120+
require.Error(t, sbom.ValidateCoverage(expected, resp))
121+
})
122+
}
123+
124+
// TestImageSBOMPropagatesScanFailures proves an unreachable image is reported as
125+
// an image-scope error rather than silently omitted from an otherwise complete
126+
// response.
127+
func TestImageSBOMPropagatesScanFailures(t *testing.T) {
128+
builder, _ := createdBuilder(t)
129+
130+
subject := &builderv0.ImageSubject{
131+
// A digest-pinned reference on a closed port fails resolution fast, and
132+
// on a host without docker fails the scan instead — an error either way.
133+
Reference: "127.0.0.1:1/missing@sha256:" + strings.Repeat("0", 64),
134+
Service: builder.Identity.Name,
135+
Role: "app",
136+
}
137+
138+
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
139+
defer cancel()
140+
resp, err := builder.SBOM(ctx, imageSBOMRequest(subject))
141+
require.NoError(t, err)
142+
143+
require.Equal(t, builderv0.SBOMStatus_ERROR, resp.GetState().GetState())
144+
require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_IMAGE, resp.GetScope())
145+
require.Empty(t, resp.GetImages())
146+
require.Error(t, sbom.ValidateCoverage([]*builderv0.ImageSubject{subject}, resp))
147+
}
148+
149+
// TestImageSBOMInventoriesTheBuiltImage is the end-to-end check: it builds the
150+
// specialization's real runtime image from the recipe this agent emits, scans
151+
// it, and requires the inventory to carry both halves a source SBOM can never
152+
// prove — the base image's OS packages and the application's installed
153+
// dependencies — bound to the digest that was actually scanned.
154+
func TestImageSBOMInventoriesTheBuiltImage(t *testing.T) {
155+
for _, tool := range []string{"docker", "uv", "syft"} {
156+
if _, err := exec.LookPath(tool); err != nil {
157+
t.Skipf("%s is not installed: the real runtime image cannot be built and scanned", tool)
158+
}
159+
}
160+
161+
builder, _ := createdBuilder(t)
162+
plan, outputDir := emitRecipePlan(t, builder)
163+
164+
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Minute)
165+
defer cancel()
166+
167+
// The Dockerfile installs from a frozen lockfile, which the scaffold does
168+
// not ship: it is resolved on first run.
169+
lock := exec.CommandContext(ctx, "uv", "lock")
170+
lock.Dir = filepath.Join(builder.Location, "code")
171+
if out, err := lock.CombinedOutput(); err != nil {
172+
t.Skipf("uv lock failed, the image cannot be built: %v\n%s", err, out)
173+
}
174+
175+
tag := fmt.Sprintf("codefly-fastapi-sbom-test:%d", time.Now().UnixMilli())
176+
// The recipe's context "." is the service directory; the Dockerfile lives
177+
// in the emitted output directory.
178+
build := exec.CommandContext(ctx, "docker", "build",
179+
"-f", filepath.Join(outputDir, plan.GetRecipes()[0].GetDockerfile()),
180+
"-t", tag, builder.Location)
181+
if out, err := build.CombinedOutput(); err != nil {
182+
t.Skipf("docker build failed, the image cannot be scanned: %v\n%s", err, out)
183+
}
184+
t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() })
185+
186+
// The image was loaded into the local daemon and never pushed, so its
187+
// immutable identity is the local image ID. The platform is left unstated:
188+
// the daemon holds exactly one, and it is read back rather than asserted.
189+
subject := &builderv0.ImageSubject{
190+
Reference: tag,
191+
Role: plan.GetRecipes()[0].GetName(),
192+
Service: builder.Identity.Name,
193+
}
194+
resp, err := builder.Base.Builder.SBOMImages(ctx, []*builderv0.ImageSubject{subject}, sbom.SourceDockerDaemon)
195+
require.NoError(t, err)
196+
require.Equal(t, builderv0.SBOMStatus_COMPLETE, resp.GetState().GetState(), resp.GetState().GetMessage())
197+
require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_IMAGE, resp.GetScope())
198+
require.Len(t, resp.GetImages(), 1)
199+
200+
evidence := resp.GetImages()[0]
201+
require.True(t, strings.HasPrefix(evidence.GetDigest(), "sha256:"), evidence.GetDigest())
202+
require.NotEmpty(t, evidence.GetPlatform())
203+
require.NotEmpty(t, evidence.GetTool())
204+
require.NotEmpty(t, evidence.GetSha256())
205+
require.Equal(t, []*builderv0.ImageSubject{subject}, evidence.GetSubjects())
206+
207+
names := map[string]bool{}
208+
for _, component := range evidence.GetBom().GetComponents() {
209+
names[strings.ToLower(component.GetName())] = true
210+
}
211+
212+
// The CI log is where this evidence is retrieved from, so name the digest
213+
// and platform the inventory is bound to rather than only asserting them.
214+
t.Logf("image evidence: digest=%s platform=%s tool=%s components=%d document-sha256=%s",
215+
evidence.GetDigest(), evidence.GetPlatform(), evidence.GetTool(),
216+
len(evidence.GetBom().GetComponents()), evidence.GetSha256())
217+
218+
// The alpine base layer: packages no lockfile inventory would ever list.
219+
var osPackages []string
220+
for _, name := range []string{"musl", "busybox", "alpine-baselayout", "ca-certificates-bundle"} {
221+
if names[name] {
222+
osPackages = append(osPackages, name)
223+
}
224+
}
225+
require.NotEmpty(t, osPackages, "image inventory carries no base OS packages")
226+
227+
// The application dependencies as they are installed in the image.
228+
for _, name := range []string{"fastapi", "uvicorn", "pydantic"} {
229+
require.True(t, names[name], "image inventory is missing application dependency %s", name)
230+
}
231+
232+
require.NoError(t, sbom.ValidateCoverage([]*builderv0.ImageSubject{subject}, resp))
233+
}

0 commit comments

Comments
 (0)