diff --git a/agents/services/sbom/golang.go b/agents/services/sbom/golang.go index 38c4cc9f..cf7dfa53 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,6 +18,7 @@ type goModule struct { Path string `json:"Path"` Version string `json:"Version"` Main bool `json:"Main"` + Dir string `json:"Dir"` Replace *goModule `json:"Replace,omitempty"` } @@ -36,11 +38,21 @@ 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) + // 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 } @@ -68,7 +80,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, 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) + } decoder := json.NewDecoder(bytes.NewReader(data)) var components []*agentv0.Component var root *agentv0.Component @@ -104,14 +120,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.Dir) + 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 directory %q", sourceDir) + } 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 directory %q", sourceDir) } return components, root, byToken, nil } @@ -125,6 +152,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 00000000..7db8c0ed --- /dev/null +++ b/agents/services/sbom/golang_test.go @@ -0,0 +1,154 @@ +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,"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), 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 b7999f4f..0609c961 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,"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)) + 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) @@ -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,"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)) + 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 756fb535..45cb36cb 100644 --- a/docs/sbom.md +++ b/docs/sbom.md @@ -10,6 +10,19 @@ 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 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` +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