From c7b6a429915a6b8bda888850b7e23b1935e28dc3 Mon Sep 17 00:00:00 2001 From: Gael Date: Fri, 30 Oct 2026 22:54:11 -0400 Subject: [PATCH 1/2] fix(sbom): retain workspace modules under the selected Go source root --- agents/services/sbom/golang.go | 39 ++++++++- agents/services/sbom/golang_test.go | 125 ++++++++++++++++++++++++++++ agents/services/sbom/sbom_test.go | 15 +++- docs/sbom.md | 12 +++ 4 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 agents/services/sbom/golang_test.go diff --git a/agents/services/sbom/golang.go b/agents/services/sbom/golang.go index 38c4cc9fd..27514e497 100644 --- a/agents/services/sbom/golang.go +++ b/agents/services/sbom/golang.go @@ -17,6 +17,7 @@ type goModule struct { Path string `json:"Path"` Version string `json:"Version"` Main bool `json:"Main"` + GoMod string `json:"GoMod"` Replace *goModule `json:"Replace,omitempty"` } @@ -36,11 +37,19 @@ func Golang(ctx context.Context, dir string) (*Result, error) { // GolangWithOptions inventories the module graph in the dependency context // explicitly selected by the language plugin. func GolangWithOptions(ctx context.Context, dir string, options GolangOptions) (*Result, error) { + goMod, err := runGo(ctx, dir, options, "env", "GOMOD") + if err != nil { + return nil, fmt.Errorf("go source module: %w", err) + } + sourceGoMod := strings.TrimSpace(string(goMod)) + if sourceGoMod == "" || sourceGoMod == os.DevNull { + return nil, fmt.Errorf("no Go source module contains %q", dir) + } modulesOut, err := runGo(ctx, dir, options, "list", "-mod=readonly", "-m", "-json", "all") if err != nil { return nil, fmt.Errorf("go module inventory: %w", err) } - modules, root, byToken, err := parseGoModules(modulesOut) + modules, root, byToken, err := parseGoModules(modulesOut, sourceGoMod) if err != nil { return nil, err } @@ -68,7 +77,11 @@ func runGo(ctx context.Context, dir string, options GolangOptions, args ...strin return stdout.Bytes(), nil } -func parseGoModules(data []byte) ([]*agentv0.Component, *agentv0.Component, map[string]string, error) { +func parseGoModules(data []byte, sourceGoMod string) ([]*agentv0.Component, *agentv0.Component, map[string]string, error) { + source, err := os.Stat(sourceGoMod) + if err != nil { + return nil, nil, nil, fmt.Errorf("stat Go source module: %w", err) + } decoder := json.NewDecoder(bytes.NewReader(data)) var components []*agentv0.Component var root *agentv0.Component @@ -104,14 +117,25 @@ func parseGoModules(data []byte) ([]*agentv0.Component, *agentv0.Component, map[ token += "@" + module.Version } byToken[token] = component.BomRef + isRoot := false if module.Main { + info, err := os.Stat(module.GoMod) + if err != nil { + return nil, nil, nil, fmt.Errorf("stat main module %q: %w", module.Path, err) + } + isRoot = os.SameFile(source, info) + } + if isRoot { + if root != nil { + return nil, nil, nil, fmt.Errorf("multiple main modules match source go.mod %q", sourceGoMod) + } root = component } else { components = append(components, component) } } if root == nil { - return nil, nil, nil, fmt.Errorf("go list returned no main module") + return nil, nil, nil, fmt.Errorf("no main module matches source go.mod %q", sourceGoMod) } return components, root, byToken, nil } @@ -125,6 +149,15 @@ func parseGoGraph(data []byte, byToken map[string]string) []*agentv0.Dependency } from, fromOK := byToken[fields[0]] to, toOK := byToken[fields[1]] + if !toOK { + // Requirements can name a version of a workspace main module. + // Only targets map to that selected module: outgoing edges of an + // unselected released version do not describe the local module. + path, _, versioned := strings.Cut(fields[1], "@") + if versioned { + to, toOK = byToken[path] + } + } if fromOK && toOK { edges[from] = append(edges[from], to) } diff --git a/agents/services/sbom/golang_test.go b/agents/services/sbom/golang_test.go new file mode 100644 index 000000000..5fe1a7a57 --- /dev/null +++ b/agents/services/sbom/golang_test.go @@ -0,0 +1,125 @@ +package sbom + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + agentv0 "github.com/codefly-dev/core/generated/go/codefly/services/agent/v0" + "github.com/stretchr/testify/require" +) + +func TestGolangWorkspaceSourceAndGraph(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + write := func(name, content string) { + path := filepath.Join(root, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) + } + write("app/go.mod", "module example.test/app\n\ngo 1.27.0\nrequire rsc.io/quote v1.5.1\n") + main := "package main\nimport (\"fmt\"; \"rsc.io/quote\")\nfunc main() { fmt.Println(quote.Hello()) }\n" + write("app/main.go", main) + write("app/cmd/demo/main.go", main) + write("sibling/go.mod", "module rsc.io/quote\n\ngo 1.27.0\n") + write("sibling/quote.go", "package quote\nfunc Hello() string { return \"workspace sibling\" }\n") + source := filepath.Join(root, "app") + _, err = runGo(t.Context(), source, GolangOptions{}, "mod", "tidy") + require.NoError(t, err) + alias := filepath.Join(root, "alias") + require.NoError(t, os.Symlink(source, alias)) + work := filepath.Join(root, "go.work") + t.Setenv("GOWORK", work) + const appRef = "pkg:golang/example.test/app" + const localQuote = "pkg:golang/rsc.io/quote" + const pinnedQuote = localQuote + "@v1.5.1" + const sampler = "pkg:golang/rsc.io/sampler@v1.3.0" + for _, order := range []string{"./app\n./sibling", "./sibling\n./app"} { + write("go.work", "go 1.27.0\nuse (\n"+order+"\n)\n") + for _, dir := range []string{source, filepath.Join(source, "cmd/demo"), alias} { + t.Run(strings.ReplaceAll(order, "\n", "_")+"/"+filepath.Base(dir), func(t *testing.T) { + options := GolangOptions{UseWorkspace: true} + listed, err := runGo(t.Context(), dir, options, "list", "-m", "-json") + require.NoError(t, err) + decoder := json.NewDecoder(strings.NewReader(string(listed))) + var mains []string + for decoder.More() { + var module goModule + require.NoError(t, decoder.Decode(&module)) + require.True(t, module.Main) + mains = append(mains, module.Path) + } + require.ElementsMatch(t, []string{"example.test/app", "rsc.io/quote"}, mains) + binary := filepath.Join(t.TempDir(), "demo") + _, err = runGo(t.Context(), dir, options, "build", "-o", binary, ".") + require.NoError(t, err) + output, err := exec.CommandContext(t.Context(), binary).CombinedOutput() + require.NoError(t, err) + require.Equal(t, "workspace sibling\n", string(output)) + result, err := GolangWithOptions(t.Context(), dir, options) + require.NoError(t, err) + require.Equal(t, appRef, result.Bom.GetMetadata().GetComponent().GetBomRef()) + refs, edges := goBOMGraph(t, result.Bom) + require.Contains(t, refs, localQuote) + require.NotContains(t, refs, pinnedQuote) + require.Contains(t, edges[appRef], localQuote) + require.NotContains(t, edges[localQuote], sampler, "released-version edges must not be attributed to the local workspace module") + attached, err := AttachArtifact(result, Artifact{Name: "demo", SHA256: strings.Repeat("a", 64)}) + require.NoError(t, err) + _, attachedEdges := goBOMGraph(t, attached.Bom) + require.Equal(t, []string{appRef}, attachedEdges[attached.Bom.GetMetadata().GetComponent().GetBomRef()]) + }) + } + } + // The default ignores even an active workspace, retaining the released graph. + pinned, err := Golang(t.Context(), source) + require.NoError(t, err) + require.Equal(t, appRef, pinned.Bom.GetMetadata().GetComponent().GetBomRef()) + refs, edges := goBOMGraph(t, pinned.Bom) + require.Contains(t, refs, pinnedQuote) + require.NotContains(t, refs, localQuote) + require.Contains(t, edges[appRef], pinnedQuote) + require.Contains(t, edges[pinnedQuote], sampler) + _, err = GolangWithOptions(t.Context(), root, GolangOptions{UseWorkspace: true}) + require.ErrorContains(t, err, "no Go source module") +} + +func goBOMGraph(t *testing.T, bom *agentv0.Bom) (map[string]bool, map[string][]string) { + t.Helper() + refs := map[string]bool{bom.GetMetadata().GetComponent().GetBomRef(): true} + for _, component := range bom.GetComponents() { + ref := component.GetBomRef() + require.NotEmpty(t, ref) + require.False(t, refs[ref], "duplicate component %s", ref) + refs[ref] = true + } + edges := map[string][]string{} + for _, dependency := range bom.GetDependencies() { + require.True(t, refs[dependency.GetRef()], "unknown graph source %s", dependency.GetRef()) + for _, ref := range dependency.GetDependsOn() { + require.True(t, refs[ref], "unknown graph target %s", ref) + } + edges[dependency.GetRef()] = dependency.GetDependsOn() + } + return refs, edges +} + +func TestParseGoModulesRequiresUniqueSource(t *testing.T) { + goMod := filepath.Join(t.TempDir(), "go.mod") + require.NoError(t, os.WriteFile(goMod, []byte("module example.test/app\n"), 0600)) + main := fmt.Sprintf(`{"Path":"example.test/app","Main":true,"GoMod":%q}`, goMod) + for _, test := range []struct{ name, modules, want string }{ + {"missing", `{ "Path":"example.test/lib", "Version":"v1.0.0" }`, "no main module matches"}, + {"duplicate", main + "\n" + main, "multiple main modules match"}, + {"unreadable", `{"Path":"example.test/app","Main":true}`, "stat main module"}, + } { + t.Run(test.name, func(t *testing.T) { + _, _, _, err := parseGoModules([]byte(test.modules), goMod) + require.ErrorContains(t, err, test.want) + }) + } +} diff --git a/agents/services/sbom/sbom_test.go b/agents/services/sbom/sbom_test.go index b7999f4f5..c84593160 100644 --- a/agents/services/sbom/sbom_test.go +++ b/agents/services/sbom/sbom_test.go @@ -4,6 +4,9 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" + "os" + "path/filepath" "strings" "testing" @@ -12,12 +15,14 @@ import ( ) func TestParseGoModulesAndGraph(t *testing.T) { + goMod := filepath.Join(t.TempDir(), "go.mod") + require.NoError(t, os.WriteFile(goMod, []byte("module example.com/app\n"), 0600)) modules := strings.Join([]string{ - `{"Path":"example.com/app","Main":true}`, + fmt.Sprintf(`{"Path":"example.com/app","Main":true,"GoMod":%q}`, goMod), `{"Path":"example.com/lib","Version":"v1.2.3"}`, `{"Path":"example.com/indirect","Version":"v0.4.0"}`, }, "\n") - components, root, byToken, err := parseGoModules([]byte(modules)) + components, root, byToken, err := parseGoModules([]byte(modules), goMod) require.NoError(t, err) require.Equal(t, "pkg:golang/example.com/app", root.GetBomRef()) require.Len(t, components, 2) @@ -62,11 +67,13 @@ func TestWrapSyftErrorSurfacesStderrAndNoSpace(t *testing.T) { } func TestParseGoModulesPreservesVersionForLocalReplacement(t *testing.T) { + goMod := filepath.Join(t.TempDir(), "go.mod") + require.NoError(t, os.WriteFile(goMod, []byte("module example.com/app\n"), 0600)) modules := strings.Join([]string{ - `{"Path":"example.com/app","Main":true}`, + fmt.Sprintf(`{"Path":"example.com/app","Main":true,"GoMod":%q}`, goMod), `{"Path":"github.com/codefly-dev/core","Version":"v0.2.20","Replace":{"Path":"/workspace/core"}}`, }, "\n") - components, _, byToken, err := parseGoModules([]byte(modules)) + components, _, byToken, err := parseGoModules([]byte(modules), goMod) require.NoError(t, err) require.Len(t, components, 1) require.Equal(t, "v0.2.20", components[0].GetVersion()) diff --git a/docs/sbom.md b/docs/sbom.md index 756fb535b..c97b2b1e5 100644 --- a/docs/sbom.md +++ b/docs/sbom.md @@ -10,6 +10,18 @@ coverage. `SBOMRequest.scope` selects which one. An unset scope means source, so callers written before image scope existed keep working unchanged. +## Go source subjects + +`sbom.Golang` disables ambient workspaces. `GolangWithOptions` can preserve the +workspace selected by the caller's build; source discovery, module listing and +the requirement graph all use that same mode. The source subject is the main +module whose `go.mod` Go selects for the requested directory, including nested +directories and filesystem aliases. Missing or ambiguous subjects are errors. +Other workspace main modules remain components. Versioned requirements on them +resolve to their selected workspace identities, without attributing outgoing +edges from an unselected released version to the local module. `AttachArtifact` +retains this graph and links the executable to its actual source subject. + ## Image subjects An image-scope request carries `subjects`: the images the caller requires From 2b077f2e127efc07eeef9e6c577685f1f5229ad7 Mon Sep 17 00:00:00 2001 From: Gael Date: Fri, 30 Oct 2026 23:01:51 -0400 Subject: [PATCH 2/2] fix(sbom): preserve alternate Go module manifests --- agents/services/sbom/golang.go | 17 +++++++++------ agents/services/sbom/golang_test.go | 33 +++++++++++++++++++++++++++-- agents/services/sbom/sbom_test.go | 8 +++---- docs/sbom.md | 5 +++-- 4 files changed, 48 insertions(+), 15 deletions(-) diff --git a/agents/services/sbom/golang.go b/agents/services/sbom/golang.go index 27514e497..cf7dfa537 100644 --- a/agents/services/sbom/golang.go +++ b/agents/services/sbom/golang.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "sort" "strings" @@ -17,7 +18,7 @@ type goModule struct { Path string `json:"Path"` Version string `json:"Version"` Main bool `json:"Main"` - GoMod string `json:"GoMod"` + Dir string `json:"Dir"` Replace *goModule `json:"Replace,omitempty"` } @@ -49,7 +50,9 @@ func GolangWithOptions(ctx context.Context, dir string, options GolangOptions) ( if err != nil { return nil, fmt.Errorf("go module inventory: %w", err) } - modules, root, byToken, err := parseGoModules(modulesOut, sourceGoMod) + // GOMOD locates the module root even when -modfile selects a different + // manifest. Compare directories, not the selected manifest filenames. + modules, root, byToken, err := parseGoModules(modulesOut, filepath.Dir(sourceGoMod)) if err != nil { return nil, err } @@ -77,8 +80,8 @@ func runGo(ctx context.Context, dir string, options GolangOptions, args ...strin return stdout.Bytes(), nil } -func parseGoModules(data []byte, sourceGoMod string) ([]*agentv0.Component, *agentv0.Component, map[string]string, error) { - source, err := os.Stat(sourceGoMod) +func parseGoModules(data []byte, sourceDir string) ([]*agentv0.Component, *agentv0.Component, map[string]string, error) { + source, err := os.Stat(sourceDir) if err != nil { return nil, nil, nil, fmt.Errorf("stat Go source module: %w", err) } @@ -119,7 +122,7 @@ func parseGoModules(data []byte, sourceGoMod string) ([]*agentv0.Component, *age byToken[token] = component.BomRef isRoot := false if module.Main { - info, err := os.Stat(module.GoMod) + info, err := os.Stat(module.Dir) if err != nil { return nil, nil, nil, fmt.Errorf("stat main module %q: %w", module.Path, err) } @@ -127,7 +130,7 @@ func parseGoModules(data []byte, sourceGoMod string) ([]*agentv0.Component, *age } if isRoot { if root != nil { - return nil, nil, nil, fmt.Errorf("multiple main modules match source go.mod %q", sourceGoMod) + return nil, nil, nil, fmt.Errorf("multiple main modules match source directory %q", sourceDir) } root = component } else { @@ -135,7 +138,7 @@ func parseGoModules(data []byte, sourceGoMod string) ([]*agentv0.Component, *age } } if root == nil { - return nil, nil, nil, fmt.Errorf("no main module matches source go.mod %q", sourceGoMod) + return nil, nil, nil, fmt.Errorf("no main module matches source directory %q", sourceDir) } return components, root, byToken, nil } diff --git a/agents/services/sbom/golang_test.go b/agents/services/sbom/golang_test.go index 5fe1a7a57..7db8c0ed8 100644 --- a/agents/services/sbom/golang_test.go +++ b/agents/services/sbom/golang_test.go @@ -111,15 +111,44 @@ func goBOMGraph(t *testing.T, bom *agentv0.Bom) (map[string]bool, map[string][]s func TestParseGoModulesRequiresUniqueSource(t *testing.T) { goMod := filepath.Join(t.TempDir(), "go.mod") require.NoError(t, os.WriteFile(goMod, []byte("module example.test/app\n"), 0600)) - main := fmt.Sprintf(`{"Path":"example.test/app","Main":true,"GoMod":%q}`, goMod) + main := fmt.Sprintf(`{"Path":"example.test/app","Main":true,"Dir":%q}`, filepath.Dir(goMod)) for _, test := range []struct{ name, modules, want string }{ {"missing", `{ "Path":"example.test/lib", "Version":"v1.0.0" }`, "no main module matches"}, {"duplicate", main + "\n" + main, "multiple main modules match"}, {"unreadable", `{"Path":"example.test/app","Main":true}`, "stat main module"}, } { t.Run(test.name, func(t *testing.T) { - _, _, _, err := parseGoModules([]byte(test.modules), goMod) + _, _, _, err := parseGoModules([]byte(test.modules), filepath.Dir(goMod)) require.ErrorContains(t, err, test.want) }) } } + +func TestGolangHonorsAlternateModuleFile(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source") + dependency := filepath.Join(root, "dependency") + require.NoError(t, os.Mkdir(source, 0700)) + require.NoError(t, os.Mkdir(dependency, 0700)) + require.NoError(t, os.WriteFile(filepath.Join(source, "go.mod"), []byte("module example.test/app\n\ngo 1.27.0\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dependency, "go.mod"), []byte("module example.test/alternate-dependency\n\ngo 1.27.0\n"), 0600)) + alternate := filepath.Join(source, "alternate.mod") + require.NoError(t, os.WriteFile(alternate, []byte("module example.test/app\n\ngo 1.27.0\nrequire example.test/alternate-dependency v1.0.0\nreplace example.test/alternate-dependency => ../dependency\n"), 0600)) + t.Setenv("GOFLAGS", "") + ordinary, err := Golang(t.Context(), source) + require.NoError(t, err) + require.Empty(t, ordinary.Bom.GetComponents()) + for _, modfile := range []string{"alternate.mod", alternate} { + t.Run(modfile, func(t *testing.T) { + t.Setenv("GOFLAGS", "-modfile="+modfile) + result, err := Golang(t.Context(), source) + require.NoError(t, err) + const appRef = "pkg:golang/example.test/app" + const depRef = "pkg:golang/example.test/alternate-dependency@v1.0.0" + require.Equal(t, appRef, result.Bom.GetMetadata().GetComponent().GetBomRef()) + refs, edges := goBOMGraph(t, result.Bom) + require.Contains(t, refs, depRef) + require.Equal(t, []string{depRef}, edges[appRef]) + }) + } +} diff --git a/agents/services/sbom/sbom_test.go b/agents/services/sbom/sbom_test.go index c84593160..0609c9615 100644 --- a/agents/services/sbom/sbom_test.go +++ b/agents/services/sbom/sbom_test.go @@ -18,11 +18,11 @@ func TestParseGoModulesAndGraph(t *testing.T) { goMod := filepath.Join(t.TempDir(), "go.mod") require.NoError(t, os.WriteFile(goMod, []byte("module example.com/app\n"), 0600)) modules := strings.Join([]string{ - fmt.Sprintf(`{"Path":"example.com/app","Main":true,"GoMod":%q}`, goMod), + fmt.Sprintf(`{"Path":"example.com/app","Main":true,"Dir":%q}`, filepath.Dir(goMod)), `{"Path":"example.com/lib","Version":"v1.2.3"}`, `{"Path":"example.com/indirect","Version":"v0.4.0"}`, }, "\n") - components, root, byToken, err := parseGoModules([]byte(modules), goMod) + components, root, byToken, err := parseGoModules([]byte(modules), filepath.Dir(goMod)) require.NoError(t, err) require.Equal(t, "pkg:golang/example.com/app", root.GetBomRef()) require.Len(t, components, 2) @@ -70,10 +70,10 @@ func TestParseGoModulesPreservesVersionForLocalReplacement(t *testing.T) { goMod := filepath.Join(t.TempDir(), "go.mod") require.NoError(t, os.WriteFile(goMod, []byte("module example.com/app\n"), 0600)) modules := strings.Join([]string{ - fmt.Sprintf(`{"Path":"example.com/app","Main":true,"GoMod":%q}`, goMod), + fmt.Sprintf(`{"Path":"example.com/app","Main":true,"Dir":%q}`, filepath.Dir(goMod)), `{"Path":"github.com/codefly-dev/core","Version":"v0.2.20","Replace":{"Path":"/workspace/core"}}`, }, "\n") - components, _, byToken, err := parseGoModules([]byte(modules), goMod) + components, _, byToken, err := parseGoModules([]byte(modules), filepath.Dir(goMod)) require.NoError(t, err) require.Len(t, components, 1) require.Equal(t, "v0.2.20", components[0].GetVersion()) diff --git a/docs/sbom.md b/docs/sbom.md index c97b2b1e5..45cb36cb7 100644 --- a/docs/sbom.md +++ b/docs/sbom.md @@ -15,8 +15,9 @@ written before image scope existed keep working unchanged. `sbom.Golang` disables ambient workspaces. `GolangWithOptions` can preserve the workspace selected by the caller's build; source discovery, module listing and the requirement graph all use that same mode. The source subject is the main -module whose `go.mod` Go selects for the requested directory, including nested -directories and filesystem aliases. Missing or ambiguous subjects are errors. +module whose directory Go selects for the requested source, including nested +directories and filesystem aliases. Alternate manifests selected by `-modfile` +retain that root and supply their own graph. Missing or ambiguous subjects are errors. Other workspace main modules remain components. Versioned requirements on them resolve to their selected workspace identities, without attributing outgoing edges from an unselected released version to the local module. `AttachArtifact`