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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ When the document declares its root, through an SPDX `DESCRIBES` edge or the Cyc
ids, declared := doc.Roots()
```

An inferred root isn't always the project: in an image, packages nothing depends on, such as `alpine-baselayout`, are roots too. `ProjectRoots` returns only the roots that are the project: the declared ones, and inferred roots syft records as coming from a lock file that lists the project (`package-lock.json`, `npm-shrinkwrap.json`, `Cargo.lock`, `uv.lock` or a yarn berry `yarn.lock`):

```go
project := doc.ProjectRoots()
```

## Ancestors

`Package.Ancestors` records the components a package was derived from, such as the upstream project of a fork. CycloneDX JSON and XML read and write these as `pedigree.ancestors`:
Expand Down
45 changes: 39 additions & 6 deletions sbom.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"errors"
"fmt"
"maps"
"path"
"slices"
"strings"
)
Expand Down Expand Up @@ -328,28 +329,60 @@ func (s *SBOM) Roots() (ids []string, declared bool) {
return ids, declared
}

// ProjectRoots returns the IDs of the roots that are the project rather than
// a top of the graph: the declared roots, and inferred roots read from a lock
// file that lists the project (see projectLockFiles). Packages of an image
// that nothing depends on are inferred roots, but not the project. It
// returns nil for a flat-list SBOM.
func (s *SBOM) ProjectRoots() []string {
ids, declared := s.Roots()
if ids == nil || declared {
return ids
}
project := s.declaredRoots()
for i := range s.Packages {
if p := &s.Packages[i]; projectLockFiles[path.Base(p.lockFile())] {
project[p.ID] = true
}
}
return slices.DeleteFunc(ids, func(id string) bool { return !project[id] })
}

// declaredRoots returns the roots the document declares: the targets of
// DESCRIBES edges and the CycloneDX metadata.component.
func (s *SBOM) declaredRoots() map[string]bool {
roots := map[string]bool{}
for _, r := range s.Relationships {
if r.Type == RelDescribes {
roots[r.TargetID] = true
}
}
if id := s.Document.Component.ID; id != "" {
roots[id] = true
}
return roots
}

// scopeRoots returns the root set for ClassifyScope, nil when there is no
// graph, the inferred roots that are top-level dependencies instead (see
// topLevelDependencies), and whether every root is declared.
func (s *SBOM) scopeRoots() (roots, top map[string]bool, declared bool) {
targets := map[string]bool{}
sources := map[string]bool{}
declaredRoots := map[string]bool{}
describes := false
for _, r := range s.Relationships {
switch r.Type {
case RelDependsOn:
sources[r.SourceID] = true
targets[r.TargetID] = true
case RelDescribes:
declaredRoots[r.TargetID] = true
describes = true
}
}
if len(sources) == 0 && len(declaredRoots) == 0 {
if len(sources) == 0 && !describes {
return nil, nil, false
}
if id := s.Document.Component.ID; id != "" {
declaredRoots[id] = true
}
declaredRoots := s.declaredRoots()
// A declared root with edges of its own names the project's direct
// dependencies, so nothing needs inferring.
linked := map[string]bool{}
Expand Down
15 changes: 12 additions & 3 deletions scope_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,25 @@ import (

// These lock files list only the project's dependencies, not the project
// itself, so a package from one that nothing depends on is a direct
// dependency rather than the project. npm's package-lock.json, Cargo.lock,
// uv.lock and yarn berry's yarn.lock do list the project, and are left to
// root inference.
// dependency rather than the project. Lock files that do list the project
// are in projectLockFiles and are left to root inference.
var projectlessLockFiles = map[string]bool{
"poetry.lock": true,
"pdm.lock": true,
"pnpm-lock.yaml": true,
yarnLockFile: true, // yarn v1; see yarnBerryWorkspaces
}

// These lock files list the project itself, so an inferred root read from
// one is the project.
var projectLockFiles = map[string]bool{
"package-lock.json": true,
"npm-shrinkwrap.json": true,
"Cargo.lock": true,
"uv.lock": true,
yarnLockFile: true, // yarn berry; yarn v1's roots are dependencies
}

const (
yarnLockFile = "yarn.lock"
// syftLocationPath is the CycloneDX property syft records the file a
Expand Down
54 changes: 52 additions & 2 deletions scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,13 +319,63 @@ func testSyftLockScope(t *testing.T, file string, want map[string]string, wantRo
// The scanned directory, which syft declares as the root without
// edges, is a root too and shows as "".
ids, declared := doc.Roots()
roots := rootNames(ids, names)
if declared != wantDeclared || !slices.Equal(roots, wantRoots) {
t.Errorf("Roots() = %v, %v; want %v, %v", roots, declared, wantRoots, wantDeclared)
}
// Every root of these lock files is the project: the scanned directory
// and the entry for the project in lock files that list it.
if got := rootNames(doc.ProjectRoots(), names); !slices.Equal(got, wantRoots) {
t.Errorf("ProjectRoots() = %v, want %v", got, wantRoots)
}
}

func rootNames(ids []string, names map[string]string) []string {
var roots []string
for _, id := range ids {
roots = append(roots, names[id])
}
slices.Sort(roots)
if declared != wantDeclared || !slices.Equal(roots, wantRoots) {
t.Errorf("Roots() = %v, %v; want %v, %v", roots, declared, wantRoots, wantDeclared)
return roots
}

func TestProjectRootsImage(t *testing.T) {
// syft declares the image as the root without edges, so packages
// nothing depends on, such as alpine-baselayout, are inferred roots.
// They are the image's packages, not the project.
tests := []struct {
file string
want []string
}{
{"cyclonedx/alpine.cdx.json", []string{""}},
{"spdx/alpine.spdx.json", []string{"docker.io/library/alpine"}},
{"cyclonedx/nginx.cdx.json", []string{""}},
{"spdx/nginx.spdx.json", []string{"docker.io/library/nginx"}},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
data, err := os.ReadFile(filepath.Join("testdata", tt.file))
if err != nil {
t.Fatal(err)
}
doc, err := Parse(data)
if err != nil {
t.Fatal(err)
}
names := map[string]string{}
for _, p := range doc.Packages {
names[p.ID] = p.Name
}
if got := rootNames(doc.ProjectRoots(), names); !slices.Equal(got, tt.want) {
t.Errorf("ProjectRoots() = %v, want %v", got, tt.want)
}
})
}
}

func TestProjectRootsFlatList(t *testing.T) {
if got := (&SBOM{}).ProjectRoots(); got != nil {
t.Errorf("ProjectRoots() = %v, want nil", got)
}
}

Expand Down
Loading