diff --git a/README.md b/README.md index 4273aef..1d54f8d 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/sbom.go b/sbom.go index a2d918a..8c2673c 100644 --- a/sbom.go +++ b/sbom.go @@ -17,6 +17,7 @@ import ( "errors" "fmt" "maps" + "path" "slices" "strings" ) @@ -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{} diff --git a/scope_lock.go b/scope_lock.go index 49f89f8..b996e61 100644 --- a/scope_lock.go +++ b/scope_lock.go @@ -7,9 +7,8 @@ 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, @@ -17,6 +16,16 @@ var projectlessLockFiles = map[string]bool{ 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 diff --git a/scope_test.go b/scope_test.go index 245fad0..1695858 100644 --- a/scope_test.go +++ b/scope_test.go @@ -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) } }