Skip to content
Draft
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
42 changes: 39 additions & 3 deletions agents/services/sbom/golang.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"

Expand All @@ -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"`
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}
Expand Down
154 changes: 154 additions & 0 deletions agents/services/sbom/golang_test.go
Original file line number Diff line number Diff line change
@@ -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])
})
}
}
15 changes: 11 additions & 4 deletions agents/services/sbom/sbom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"

Expand All @@ -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)
Expand Down Expand Up @@ -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())
Expand Down
13 changes: 13 additions & 0 deletions docs/sbom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading