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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ sbom.Encode(os.Stdout, s, sbom.FormatSPDXJSON)

## Relationships

Both parsers normalise dependency edges to `sbom.RelDependsOn` and SPDX's document-to-root link to `sbom.RelDescribes`. `ClassifyScope` walks that graph to tag each package as a direct or transitive dependency of the document's root:
Both parsers normalise dependency edges to `sbom.RelDependsOn`, SPDX `DEPENDENCY_OF` edges included with their direction reversed, and SPDX's document-to-root link to `sbom.RelDescribes`. `ClassifyScope` walks that graph to tag each package as a direct or transitive dependency of the document's root:

```go
doc, _ := sbom.Parse(data)
Expand All @@ -91,6 +91,14 @@ for _, p := range doc.Packages {

`ClassifyScope` returns `nil` when the document has no dependency graph at all, so callers can hide a scope filter rather than showing every package as unclassified.

When the document declares its root, through an SPDX `DESCRIBES` edge or the CycloneDX `metadata.component` bom-ref (kept as `Document.Component.ID`), and that root has dependency edges, it is the only root. Otherwise roots are inferred as the nodes that have dependencies but no dependents. That is the project for lock files that list it, such as `package-lock.json`, `Cargo.lock` and `uv.lock`. Lock files such as `poetry.lock` list only dependencies, so the inferred roots would be the top-level dependencies. For packages syft records as coming from `poetry.lock`, `pdm.lock`, `pnpm-lock.yaml` or a yarn v1 `yarn.lock` (the `syft:location:0:path` property in CycloneDX, `PackageSourceInfo` in SPDX), those are classified as direct instead, as is any package from such a lock file that no edge names, such as a dependency without dependencies of its own, as long as the lock file has edges at all. Other generators' lock-file SBOMs that don't link the project may still have top-level dependencies inferred as roots.

`Roots` returns the root IDs and whether the document declared all of them, so a caller can tell the project apart from an inferred root:

```go
ids, declared := doc.Roots()
```

## 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
1 change: 1 addition & 0 deletions cyclonedx.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ func normalizeCycloneDX(bom *cdxBOM) *SBOM {
if m.Component != nil {
s.Document.Name = m.Component.Name
s.Document.Component = Component{
ID: m.Component.BOMRef,
Type: m.Component.Type,
Name: m.Component.Name,
Version: m.Component.Version,
Expand Down
3 changes: 2 additions & 1 deletion encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ func buildCycloneDX(s *SBOM) *cdxBOM {
}
if c := s.Document.Component; c.Name != "" {
bom.Metadata.Component = &cdxComponent{
Type: firstNonEmpty(c.Type, "application"), Name: c.Name, Version: c.Version,
BOMRef: c.ID,
Type: firstNonEmpty(c.Type, "application"), Name: c.Name, Version: c.Version,
Licenses: componentLicensesToCDX(c),
}
}
Expand Down
134 changes: 106 additions & 28 deletions sbom.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"strings"
)
Expand Down Expand Up @@ -61,8 +62,11 @@ type Creator struct {
}

// Component is the root subject the SBOM describes (CycloneDX
// metadata.component / SPDX root package).
// metadata.component / SPDX root package). ID is its CycloneDX bom-ref, the
// identifier dependency edges from the root use; it is empty when the
// document gives none.
type Component struct {
ID string
Type string
Name string
Version string
Expand Down Expand Up @@ -117,6 +121,9 @@ type Package struct {
Homepage string
DownloadLocation string
Filename string
// SourceInfo is SPDX's free-text note on where the package information
// came from (PackageSourceInfo).
SourceInfo string
LicenseConcluded string
LicenseDeclared string
Copyright string
Expand Down Expand Up @@ -162,13 +169,16 @@ func (p *Package) CPE() string {
}

// Relationship type values. SPDX has ~40 of these; we name the two the
// parsers emit and ClassifyScope reads. CycloneDX dependency edges are
// normalised to RelDependsOn.
// parsers emit and ClassifyScope reads. CycloneDX dependency edges and
// SPDX DEPENDENCY_OF edges are normalised to RelDependsOn.
const (
RelDependsOn = "DEPENDS_ON"
RelDescribes = "DESCRIBES"
)

// spdxDependencyOf is the SPDX inverse of DEPENDS_ON, which syft emits.
const spdxDependencyOf = "DEPENDENCY_OF"

// Scope values returned by ClassifyScope.
const (
ScopeDirect = "direct"
Expand Down Expand Up @@ -252,39 +262,38 @@ func (s *SBOM) FilterProperties(keep func(name string) bool) {
}

// ClassifyScope derives direct-vs-transitive scope for each package from
// the relationship graph. Roots are nodes that originate DEPENDS_ON edges
// but are never themselves a DEPENDS_ON target, plus anything a DESCRIBES
// edge points at (SPDX's document-to-root-package link). A package is
// ScopeDirect if a root depends on it, ScopeTransitive if only a non-root
// does, and absent from the map if the graph doesn't mention it at all.
// the relationship graph. A package is ScopeDirect if a root depends on it,
// ScopeTransitive if only a non-root does, and absent from the map if the
// graph doesn't mention it at all.
//
// When the document declares its root, as the target of a DESCRIBES edge
// (SPDX's document-to-root-package link) or as Document.Component.ID (the
// CycloneDX metadata.component bom-ref), and that root has DEPENDS_ON edges,
// the declared roots are the only roots. Otherwise roots are inferred: nodes
// that originate DEPENDS_ON edges but are never a DEPENDS_ON target, plus any
// declared root. An inferred root is not necessarily the project: for a lock
// file that doesn't list the project itself the top-level dependencies are
// inferred as roots, except for packages syft records as coming from such a
// lock file (poetry.lock, pdm.lock, pnpm-lock.yaml and yarn v1's yarn.lock):
// those are ScopeDirect themselves, and what they depend on is transitive.
// A package from such a lock file that no edge names is ScopeDirect too,
// when other packages of the lock file have edges.
//
// Returns nil for a flat-list SBOM with no dependency graph so callers can
// tell "no graph" apart from "graph present but this package is a root".
func (s *SBOM) ClassifyScope() map[string]string {
targets := map[string]bool{}
sources := map[string]bool{}
roots := map[string]bool{}
for _, r := range s.Relationships {
switch r.Type {
case RelDependsOn:
sources[r.SourceID] = true
targets[r.TargetID] = true
case RelDescribes:
roots[r.TargetID] = true
}
}
if len(sources) == 0 && len(roots) == 0 {
roots, top, _ := s.scopeRoots()
if roots == nil {
// No DEPENDS_ON or DESCRIBES edges: a flat-list SBOM. nil (as
// distinct from empty) lets callers hide scope entirely.
return nil
}
for id := range sources {
if !targets[id] {
roots[id] = true
}
}
out := map[string]string{}
if len(roots) == 0 {
// Edges exist but form a cycle with no DESCRIBES root: nothing
for id := range top {
out[id] = ScopeDirect
}
if len(roots) == 0 && len(top) == 0 {
// Edges exist but form a cycle with no declared root: nothing
// is classifiable, but the graph is present, so return empty
// rather than nil.
return out
Expand All @@ -302,6 +311,75 @@ func (s *SBOM) ClassifyScope() map[string]string {
return out
}

// Roots returns the IDs ClassifyScope treats as roots, and whether all of
// them are declared by the document rather than inferred from the graph. Declared
// roots are the project; inferred ones may be top-level dependencies of a
// lock file that leaves the project out. It returns nil for a flat-list SBOM.
func (s *SBOM) Roots() (ids []string, declared bool) {
roots, _, declared := s.scopeRoots()
if roots == nil {
return nil, false
}
ids = make([]string, 0, len(roots))
for id := range roots {
ids = append(ids, id)
}
slices.Sort(ids)
return ids, declared
}

// 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{}
for _, r := range s.Relationships {
switch r.Type {
case RelDependsOn:
sources[r.SourceID] = true
targets[r.TargetID] = true
case RelDescribes:
declaredRoots[r.TargetID] = true
}
}
if len(sources) == 0 && len(declaredRoots) == 0 {
return nil, nil, false
}
if id := s.Document.Component.ID; id != "" {
declaredRoots[id] = true
}
// A declared root with edges of its own names the project's direct
// dependencies, so nothing needs inferring.
linked := map[string]bool{}
for id := range declaredRoots {
if sources[id] {
linked[id] = true
}
}
if len(linked) > 0 {
return linked, nil, true
}
inferred := map[string]bool{}
for id := range sources {
if !targets[id] {
inferred[id] = true
}
}
edged := maps.Clone(sources)
maps.Copy(edged, targets)
top = s.topLevelDependencies(inferred, edged)
roots = maps.Clone(declaredRoots)
for id := range inferred {
if !top[id] {
roots[id] = true
}
}
declared = len(roots) > 0 && len(roots) == len(declaredRoots)
return roots, top, declared
}

func (s *SBOM) addPackage(p Package) {
if s.pkgIndex == nil {
s.pkgIndex = map[packageKey]int{}
Expand Down
105 changes: 105 additions & 0 deletions scope_lock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package sbom

import (
"path"
"strings"
)

// 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.
var projectlessLockFiles = map[string]bool{
"poetry.lock": true,
"pdm.lock": true,
"pnpm-lock.yaml": true,
"yarn.lock": true, // yarn v1; see yarnBerryWorkspaces
}

const (
// syftLocationPath is the CycloneDX property syft records the file a
// package was read from in.
syftLocationPath = "syft:location:0:path"
// yarnBerryWorkspaceVersion is the version yarn berry gives the
// workspace entries it records for the project.
yarnBerryWorkspaceVersion = "0.0.0-use.local"
)

// lockFile returns the path of the file syft read p from: the location
// property in CycloneDX, or the path ending SPDX's PackageSourceInfo, as in
// "acquired package info from installed python package manifest file:
// /poetry.lock".
func (p *Package) lockFile() string {
if loc := p.property(syftLocationPath); loc != "" {
return loc
}
if i := strings.LastIndex(p.SourceInfo, ": /"); i >= 0 {
return p.SourceInfo[i+2:]
}
return ""
}

// topLevelDependencies returns the IDs of packages from a lock file that
// leaves the project out which nothing depends on, so are dependencies of
// the project rather than the project itself: the inferred roots among them,
// and those without any edge, such as a dependency that has none of its own.
// A package without edges counts only when other packages of its lock file
// have edges, so a lock file the graph leaves out stays unclassified.
// edged holds the IDs that are the source or target of a DEPENDS_ON edge.
func (s *SBOM) topLevelDependencies(inferred, edged map[string]bool) map[string]bool {
var berryLocks map[string]bool
lockHasEdges := map[string]bool{}
for i := range s.Packages {
if p := &s.Packages[i]; edged[p.ID] {
lockHasEdges[p.lockFile()] = true
}
}
var top map[string]bool
for i := range s.Packages {
p := &s.Packages[i]
lock := p.lockFile()
if !inferred[p.ID] && (edged[p.ID] || !lockHasEdges[lock]) {
continue
}
name := path.Base(lock)
if lock == "" || !projectlessLockFiles[name] {
continue
}
if name == "yarn.lock" {
if berryLocks == nil {
berryLocks = s.yarnBerryWorkspaces()
}
if berryLocks[lock] {
continue
}
}
if top == nil {
top = map[string]bool{}
}
top[p.ID] = true
}
return top
}

// yarnBerryWorkspaces returns the yarn.lock paths that record a workspace
// entry, which yarn v1 lock files don't have.
func (s *SBOM) yarnBerryWorkspaces() map[string]bool {
locks := map[string]bool{}
for i := range s.Packages {
p := &s.Packages[i]
if lock := p.lockFile(); p.Version == yarnBerryWorkspaceVersion && path.Base(lock) == "yarn.lock" {
locks[lock] = true
}
}
return locks
}

func (p *Package) property(name string) string {
for _, prop := range p.Properties {
if prop.Name == name {
return prop.Value
}
}
return ""
}
Loading
Loading