From 0f7aba3fa304d8c7916db317ef2a80a8b5aaf115 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Fri, 30 Oct 2026 19:37:50 +0100 Subject: [PATCH 1/5] Classify scope from the declared root and from syft lock entries ClassifyScope took any node with dependencies and no dependents as a root. For a lock file that doesn't list the project, such as poetry.lock, that made the top-level dependencies roots, so they dropped out and their dependencies were reported as direct. The CycloneDX metadata.component bom-ref is now kept as Document.Component.ID. A declared root (that or a DESCRIBES target) with dependency edges is the only root. Without one, roots are inferred as before, except that packages syft records as coming from poetry.lock, pdm.lock, pnpm-lock.yaml or a yarn v1 yarn.lock are direct dependencies rather than roots. Roots reports the root IDs and whether they were declared. Fixes #23 --- README.md | 8 + cyclonedx.go | 1 + encode.go | 3 +- sbom.go | 118 +++++++++--- scope_lock.go | 75 ++++++++ scope_test.go | 183 +++++++++++++++++- testdata/cyclonedx/syft-lock/berry.cdx.json | 145 ++++++++++++++ testdata/cyclonedx/syft-lock/cargo.cdx.json | 192 +++++++++++++++++++ testdata/cyclonedx/syft-lock/npm.cdx.json | 145 ++++++++++++++ testdata/cyclonedx/syft-lock/pdm.cdx.json | 109 +++++++++++ testdata/cyclonedx/syft-lock/pnpm9.cdx.json | 109 +++++++++++ testdata/cyclonedx/syft-lock/poetry.cdx.json | 109 +++++++++++ testdata/cyclonedx/syft-lock/uv.cdx.json | 189 ++++++++++++++++++ testdata/cyclonedx/syft-lock/yarn.cdx.json | 109 +++++++++++ 14 files changed, 1467 insertions(+), 28 deletions(-) create mode 100644 scope_lock.go create mode 100644 testdata/cyclonedx/syft-lock/berry.cdx.json create mode 100644 testdata/cyclonedx/syft-lock/cargo.cdx.json create mode 100644 testdata/cyclonedx/syft-lock/npm.cdx.json create mode 100644 testdata/cyclonedx/syft-lock/pdm.cdx.json create mode 100644 testdata/cyclonedx/syft-lock/pnpm9.cdx.json create mode 100644 testdata/cyclonedx/syft-lock/poetry.cdx.json create mode 100644 testdata/cyclonedx/syft-lock/uv.cdx.json create mode 100644 testdata/cyclonedx/syft-lock/yarn.cdx.json diff --git a/README.md b/README.md index ffd091b..785b3e2 100644 --- a/README.md +++ b/README.md @@ -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`, those are classified as direct instead. 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 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`: diff --git a/cyclonedx.go b/cyclonedx.go index ecac6ba..da090c8 100644 --- a/cyclonedx.go +++ b/cyclonedx.go @@ -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, diff --git a/encode.go b/encode.go index 82e67c4..a4534ef 100644 --- a/encode.go +++ b/encode.go @@ -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), } } diff --git a/sbom.go b/sbom.go index 2d48762..89c126c 100644 --- a/sbom.go +++ b/sbom.go @@ -61,8 +61,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 @@ -252,39 +255,36 @@ 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. +// // 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 @@ -302,6 +302,72 @@ func (s *SBOM) ClassifyScope() map[string]string { return out } +// Roots returns the IDs ClassifyScope treats as roots, and whether they 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 the roots are 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 + } + } + top = s.topLevelDependencies(inferred) + roots = declaredRoots + for id := range inferred { + if !top[id] { + roots[id] = true + } + } + return roots, top, false +} + func (s *SBOM) addPackage(p Package) { if s.pkgIndex == nil { s.pkgIndex = map[packageKey]int{} diff --git a/scope_lock.go b/scope_lock.go new file mode 100644 index 0000000..1fceb0c --- /dev/null +++ b/scope_lock.go @@ -0,0 +1,75 @@ +package sbom + +// syft records which lock file entry a package came from in the +// syft:package:metadataType property. 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 projectlessLockEntries = map[string]bool{ + "python-poetry-lock-entry": true, + "python-pdm-lock-entry": true, + "javascript-pnpm-lock-entry": true, + "javascript-yarn-lock-entry": true, // yarn v1; see yarnBerryWorkspaces +} + +const ( + syftMetadataType = "syft:package:metadataType" + 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" +) + +// topLevelDependencies returns the IDs among candidates that are packages +// from a lock file that leaves the project out, so are dependencies of the +// project rather than the project itself. +func (s *SBOM) topLevelDependencies(candidates map[string]bool) map[string]bool { + var berryLocks map[string]bool + var top map[string]bool + for i := range s.Packages { + p := &s.Packages[i] + if !candidates[p.ID] { + continue + } + kind := p.property(syftMetadataType) + if !projectlessLockEntries[kind] { + continue + } + if kind == "javascript-yarn-lock-entry" { + if berryLocks == nil { + berryLocks = s.yarnBerryWorkspaces() + } + if berryLocks[p.property(syftLocationPath)] { + 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 p.Version == yarnBerryWorkspaceVersion && p.property(syftMetadataType) == "javascript-yarn-lock-entry" { + locks[p.property(syftLocationPath)] = true + } + } + return locks +} + +func (p *Package) property(name string) string { + for _, prop := range p.Properties { + if prop.Name == name { + return prop.Value + } + } + return "" +} diff --git a/scope_test.go b/scope_test.go index 4de5884..3c01b73 100644 --- a/scope_test.go +++ b/scope_test.go @@ -1,6 +1,13 @@ package sbom -import "testing" +import ( + "bytes" + "maps" + "os" + "path/filepath" + "slices" + "testing" +) func TestClassifyScope(t *testing.T) { t.Run("cyclonedx graph", func(t *testing.T) { @@ -102,3 +109,177 @@ func TestClassifyScope(t *testing.T) { } }) } + +func TestClassifyScopeDeclaredRoot(t *testing.T) { + t.Run("cyclonedx component with edges is the only root", func(t *testing.T) { + // The project links requests; the stray lock entry orphan -> x is + // not a root once the project is declared. + s := &SBOM{ + Document: Document{Component: Component{ID: "project"}}, + Relationships: []Relationship{ + {SourceID: "project", TargetID: "requests", Type: RelDependsOn}, + {SourceID: "requests", TargetID: "urllib3", Type: RelDependsOn}, + {SourceID: "orphan", TargetID: "x", Type: RelDependsOn}, + }, + } + got := s.ClassifyScope() + want := map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive, "x": ScopeTransitive} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for id, sc := range want { + if got[id] != sc { + t.Errorf("%s = %q, want %q", id, got[id], sc) + } + } + if ids, declared := s.Roots(); !declared || len(ids) != 1 || ids[0] != "project" { + t.Errorf("Roots() = %v, %v; want [project], true", ids, declared) + } + }) + t.Run("spdx DESCRIBES target with edges is the only root", func(t *testing.T) { + s := &SBOM{Relationships: []Relationship{ + {SourceID: "SPDXRef-DOCUMENT", TargetID: "root", Type: RelDescribes}, + {SourceID: "root", TargetID: "a", Type: RelDependsOn}, + {SourceID: "other", TargetID: "b", Type: RelDependsOn}, + }} + got := s.ClassifyScope() + if got["a"] != ScopeDirect || got["b"] != ScopeTransitive { + t.Errorf("got %v", got) + } + if _, declared := s.Roots(); !declared { + t.Error("Roots() declared = false, want true") + } + }) + t.Run("declared root without edges falls back to inference", func(t *testing.T) { + // syft's CycloneDX for a poetry.lock: metadata.component is the + // scanned directory with no edges, so requests is inferred as a root. + s := &SBOM{ + Document: Document{Component: Component{ID: "dir"}}, + Relationships: []Relationship{ + {SourceID: "requests", TargetID: "urllib3", Type: RelDependsOn}, + }, + } + got := s.ClassifyScope() + if len(got) != 1 || got["urllib3"] != ScopeDirect { + t.Errorf("got %v, want urllib3 direct", got) + } + ids, declared := s.Roots() + if declared { + t.Error("Roots() declared = true, want false") + } + if len(ids) != 2 || ids[0] != "dir" || ids[1] != "requests" { + t.Errorf("Roots() = %v, want [dir requests]", ids) + } + }) + t.Run("no graph", func(t *testing.T) { + s := &SBOM{Document: Document{Component: Component{ID: "project"}}} + if got := s.ClassifyScope(); got != nil { + t.Errorf("got %v, want nil", got) + } + if ids, declared := s.Roots(); ids != nil || declared { + t.Errorf("Roots() = %v, %v; want nil, false", ids, declared) + } + }) +} + +func TestClassifyScopeCycloneDXMetadataComponent(t *testing.T) { + data := []byte(`{ + "bomFormat": "CycloneDX", "specVersion": "1.6", + "metadata": {"component": {"bom-ref": "app", "type": "application", "name": "app"}}, + "components": [ + {"bom-ref": "requests", "type": "library", "name": "requests", "version": "2.31.0"}, + {"bom-ref": "urllib3", "type": "library", "name": "urllib3", "version": "2.1.0"}, + {"bom-ref": "pytest", "type": "library", "name": "pytest", "version": "8.0.0"}, + {"bom-ref": "pluggy", "type": "library", "name": "pluggy", "version": "1.4.0"} + ], + "dependencies": [ + {"ref": "app", "dependsOn": ["requests"]}, + {"ref": "requests", "dependsOn": ["urllib3"]}, + {"ref": "pytest", "dependsOn": ["pluggy"]} + ] +}`) + doc, err := Parse(data) + if err != nil { + t.Fatal(err) + } + if doc.Document.Component.ID != "app" { + t.Fatalf("Component.ID = %q, want app", doc.Document.Component.ID) + } + got := doc.ClassifyScope() + want := map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive, "pluggy": ScopeTransitive} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for id, sc := range want { + if got[id] != sc { + t.Errorf("%s = %q, want %q", id, got[id], sc) + } + } + + var buf bytes.Buffer + if err := Encode(&buf, doc, FormatCycloneDXJSON); err != nil { + t.Fatal(err) + } + again, err := Parse(buf.Bytes()) + if err != nil { + t.Fatal(err) + } + if again.Document.Component.ID != "app" { + t.Errorf("encoded Component.ID = %q, want app", again.Document.Component.ID) + } +} + +// syft's CycloneDX output for one small project per lock format, generated +// with syft 1.55.0. Lock files that list the project (package-lock.json, +// Cargo.lock, uv.lock, yarn berry) give it a node of its own; the others +// list only dependencies. +func TestClassifyScopeSyftLockFiles(t *testing.T) { + tests := []struct { + file string + want map[string]string + roots []string + }{ + {"npm", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{"", "app"}}, + {"cargo", map[string]string{"serde": ScopeDirect, "serde_derive": ScopeTransitive}, []string{"", "app"}}, + {"uv", map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive}, []string{"", "app"}}, + {"berry", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{"", "app"}}, + {"poetry", map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive}, []string{""}}, + {"pdm", map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive}, []string{""}}, + {"pnpm9", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{""}}, + {"yarn", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{""}}, + } + for _, tt := range tests { + t.Run(tt.file, func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "cyclonedx", "syft-lock", tt.file+".cdx.json")) + 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 + } + got := map[string]string{} + for id, sc := range doc.ClassifyScope() { + got[names[id]] = sc + } + if !maps.Equal(got, tt.want) { + t.Errorf("scope = %v, want %v", got, tt.want) + } + // The scanned directory, which syft names in metadata.component + // without edges, is a root too; it has no package entry. + ids, declared := doc.Roots() + var roots []string + for _, id := range ids { + roots = append(roots, names[id]) + } + slices.Sort(roots) + if declared || !slices.Equal(roots, tt.roots) { + t.Errorf("Roots() = %v, %v; want %v, false", roots, declared, tt.roots) + } + }) + } +} diff --git a/testdata/cyclonedx/syft-lock/berry.cdx.json b/testdata/cyclonedx/syft-lock/berry.cdx.json new file mode 100644 index 0000000..fa63369 --- /dev/null +++ b/testdata/cyclonedx/syft-lock/berry.cdx.json @@ -0,0 +1,145 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "version": 1, + "metadata": { + "timestamp": "2026-10-30T00:00:00Z", + "tools": { + "components": [ + { + "type": "application", + "author": "anchore", + "name": "syft", + "version": "1.55.0" + } + ] + }, + "component": { + "bom-ref": "8fedd5be3705aa73", + "type": "file", + "name": "berry" + } + }, + "components": [ + { + "bom-ref": "pkg:npm/accepts@1.3.8?package-id=405ed17ac849bb64", + "type": "library", + "name": "accepts", + "version": "1.3.8", + "cpe": "cpe:2.3:a:accepts:accepts:1.3.8:*:*:*:*:*:*:*", + "purl": "pkg:npm/accepts@1.3.8", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "javascript-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "javascript" + }, + { + "name": "syft:package:type", + "value": "npm" + }, + { + "name": "syft:package:metadataType", + "value": "javascript-yarn-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/yarn.lock" + } + ] + }, + { + "bom-ref": "pkg:npm/app@0.0.0-use.local?package-id=0d57075a66eeffef", + "type": "library", + "name": "app", + "version": "0.0.0-use.local", + "cpe": "cpe:2.3:a:app:app:0.0.0-use.local:*:*:*:*:*:*:*", + "purl": "pkg:npm/app@0.0.0-use.local", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "javascript-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "javascript" + }, + { + "name": "syft:package:type", + "value": "npm" + }, + { + "name": "syft:package:metadataType", + "value": "javascript-yarn-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/yarn.lock" + } + ] + }, + { + "bom-ref": "pkg:npm/express@4.18.2?package-id=c0b33931be4c6bc1", + "type": "library", + "name": "express", + "version": "4.18.2", + "cpe": "cpe:2.3:a:openjsf:express:4.18.2:*:*:*:*:node.js:*:*", + "purl": "pkg:npm/express@4.18.2", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "javascript-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "javascript" + }, + { + "name": "syft:package:type", + "value": "npm" + }, + { + "name": "syft:package:metadataType", + "value": "javascript-yarn-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/yarn.lock" + } + ] + }, + { + "bom-ref": "dcee26e98fae7b36", + "type": "file", + "name": "berry/yarn.lock", + "hashes": [ + { + "alg": "SHA-1", + "content": "25106461e83f05874e95ae1ff4354d48e8d90c05" + }, + { + "alg": "SHA-256", + "content": "391d34344bad7be85707d66eb6c3de76ba8f16d30f37490af7bdcb31c3b02d6d" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/app@0.0.0-use.local?package-id=0d57075a66eeffef", + "dependsOn": [ + "pkg:npm/express@4.18.2?package-id=c0b33931be4c6bc1" + ] + }, + { + "ref": "pkg:npm/express@4.18.2?package-id=c0b33931be4c6bc1", + "dependsOn": [ + "pkg:npm/accepts@1.3.8?package-id=405ed17ac849bb64" + ] + } + ] +} diff --git a/testdata/cyclonedx/syft-lock/cargo.cdx.json b/testdata/cyclonedx/syft-lock/cargo.cdx.json new file mode 100644 index 0000000..b7b8aaa --- /dev/null +++ b/testdata/cyclonedx/syft-lock/cargo.cdx.json @@ -0,0 +1,192 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "version": 1, + "metadata": { + "timestamp": "2026-10-30T00:00:00Z", + "tools": { + "components": [ + { + "type": "application", + "author": "anchore", + "name": "syft", + "version": "1.55.0" + } + ] + }, + "component": { + "bom-ref": "cf2ed2b38a4108cf", + "type": "file", + "name": "cargo" + } + }, + "components": [ + { + "bom-ref": "728030f2ab43f725", + "type": "library", + "name": "app", + "version": "0.1.0", + "cpe": "cpe:2.3:a:app_project:app:0.1.0:*:*:*:*:rust:*:*", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "rust-cargo-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "rust" + }, + { + "name": "syft:package:type", + "value": "rust-crate" + }, + { + "name": "syft:package:metadataType", + "value": "rust-cargo-lock-entry" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:app:app:0.1.0:*:*:*:*:rust:*:*" + }, + { + "name": "syft:location:0:path", + "value": "/Cargo.lock" + } + ] + }, + { + "bom-ref": "pkg:cargo/serde@1.0.190?package-id=9981dc59cb3e1cb9", + "type": "library", + "name": "serde", + "version": "1.0.190", + "cpe": "cpe:2.3:a:serde_project:serde:1.0.190:*:*:*:*:rust:*:*", + "purl": "pkg:cargo/serde@1.0.190", + "externalReferences": [ + { + "url": "registry+https://github.com/rust-lang/crates.io-index", + "type": "distribution" + } + ], + "properties": [ + { + "name": "syft:package:foundBy", + "value": "rust-cargo-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "rust" + }, + { + "name": "syft:package:type", + "value": "rust-crate" + }, + { + "name": "syft:package:metadataType", + "value": "rust-cargo-lock-entry" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:serde:serde:1.0.190:*:*:*:*:rust:*:*" + }, + { + "name": "syft:location:0:path", + "value": "/Cargo.lock" + } + ] + }, + { + "bom-ref": "pkg:cargo/serde_derive@1.0.190?package-id=339127215a83e280", + "type": "library", + "name": "serde_derive", + "version": "1.0.190", + "cpe": "cpe:2.3:a:serde_derive_project:serde-derive:1.0.190:*:*:*:*:rust:*:*", + "purl": "pkg:cargo/serde_derive@1.0.190", + "externalReferences": [ + { + "url": "registry+https://github.com/rust-lang/crates.io-index", + "type": "distribution" + } + ], + "properties": [ + { + "name": "syft:package:foundBy", + "value": "rust-cargo-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "rust" + }, + { + "name": "syft:package:type", + "value": "rust-crate" + }, + { + "name": "syft:package:metadataType", + "value": "rust-cargo-lock-entry" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:serde_derive_project:serde_derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:serde-derive:serde-derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:serde-derive:serde_derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:serde_derive:serde-derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:serde_derive:serde_derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:serde:serde-derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:serde:serde_derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "name": "syft:location:0:path", + "value": "/Cargo.lock" + } + ] + }, + { + "bom-ref": "c6bea2c24af05bc1", + "type": "file", + "name": "cargo/Cargo.lock", + "hashes": [ + { + "alg": "SHA-1", + "content": "df758add487e58b2c5457b120f531158bb96b89d" + }, + { + "alg": "SHA-256", + "content": "8e2de07cf4632c9e09df9a44ad97ce9a402b10ad168101805ca99a5ae79fd0e1" + } + ] + } + ], + "dependencies": [ + { + "ref": "728030f2ab43f725", + "dependsOn": [ + "pkg:cargo/serde@1.0.190?package-id=9981dc59cb3e1cb9" + ] + }, + { + "ref": "pkg:cargo/serde@1.0.190?package-id=9981dc59cb3e1cb9", + "dependsOn": [ + "pkg:cargo/serde_derive@1.0.190?package-id=339127215a83e280" + ] + } + ] +} diff --git a/testdata/cyclonedx/syft-lock/npm.cdx.json b/testdata/cyclonedx/syft-lock/npm.cdx.json new file mode 100644 index 0000000..c743db7 --- /dev/null +++ b/testdata/cyclonedx/syft-lock/npm.cdx.json @@ -0,0 +1,145 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "version": 1, + "metadata": { + "timestamp": "2026-10-30T00:00:00Z", + "tools": { + "components": [ + { + "type": "application", + "author": "anchore", + "name": "syft", + "version": "1.55.0" + } + ] + }, + "component": { + "bom-ref": "d8b0ae186b8a4344", + "type": "file", + "name": "npm" + } + }, + "components": [ + { + "bom-ref": "pkg:npm/accepts@1.3.8?package-id=27587463afb4ef54", + "type": "library", + "name": "accepts", + "version": "1.3.8", + "cpe": "cpe:2.3:a:accepts:accepts:1.3.8:*:*:*:*:*:*:*", + "purl": "pkg:npm/accepts@1.3.8", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "javascript-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "javascript" + }, + { + "name": "syft:package:type", + "value": "npm" + }, + { + "name": "syft:package:metadataType", + "value": "javascript-npm-package-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/package-lock.json" + } + ] + }, + { + "bom-ref": "pkg:npm/app@1.0.0?package-id=d612e2e66643c207", + "type": "library", + "name": "app", + "version": "1.0.0", + "cpe": "cpe:2.3:a:app:app:1.0.0:*:*:*:*:*:*:*", + "purl": "pkg:npm/app@1.0.0", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "javascript-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "javascript" + }, + { + "name": "syft:package:type", + "value": "npm" + }, + { + "name": "syft:package:metadataType", + "value": "javascript-npm-package-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/package-lock.json" + } + ] + }, + { + "bom-ref": "pkg:npm/express@4.18.2?package-id=c3444b233839bfbe", + "type": "library", + "name": "express", + "version": "4.18.2", + "cpe": "cpe:2.3:a:openjsf:express:4.18.2:*:*:*:*:node.js:*:*", + "purl": "pkg:npm/express@4.18.2", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "javascript-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "javascript" + }, + { + "name": "syft:package:type", + "value": "npm" + }, + { + "name": "syft:package:metadataType", + "value": "javascript-npm-package-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/package-lock.json" + } + ] + }, + { + "bom-ref": "fd71c2238fc07657", + "type": "file", + "name": "npm/package-lock.json", + "hashes": [ + { + "alg": "SHA-1", + "content": "cb30ec046e1244667495b6eaedaf0d69dfedcd7e" + }, + { + "alg": "SHA-256", + "content": "80fbfd82abcabbd080a02de72c130647c1194af899ff99ab3c9997bb23d5de00" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/app@1.0.0?package-id=d612e2e66643c207", + "dependsOn": [ + "pkg:npm/express@4.18.2?package-id=c3444b233839bfbe" + ] + }, + { + "ref": "pkg:npm/express@4.18.2?package-id=c3444b233839bfbe", + "dependsOn": [ + "pkg:npm/accepts@1.3.8?package-id=27587463afb4ef54" + ] + } + ] +} diff --git a/testdata/cyclonedx/syft-lock/pdm.cdx.json b/testdata/cyclonedx/syft-lock/pdm.cdx.json new file mode 100644 index 0000000..14246f1 --- /dev/null +++ b/testdata/cyclonedx/syft-lock/pdm.cdx.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "version": 1, + "metadata": { + "timestamp": "2026-10-30T00:00:00Z", + "tools": { + "components": [ + { + "type": "application", + "author": "anchore", + "name": "syft", + "version": "1.55.0" + } + ] + }, + "component": { + "bom-ref": "d8a9ce186b845ab6", + "type": "file", + "name": "pdm" + } + }, + "components": [ + { + "bom-ref": "pkg:pypi/requests@2.31.0?package-id=d97d1ee1c7a2d435", + "type": "library", + "name": "requests", + "version": "2.31.0", + "cpe": "cpe:2.3:a:python:requests:2.31.0:*:*:*:*:*:*:*", + "purl": "pkg:pypi/requests@2.31.0", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "python-package-cataloger" + }, + { + "name": "syft:package:language", + "value": "python" + }, + { + "name": "syft:package:type", + "value": "python" + }, + { + "name": "syft:package:metadataType", + "value": "python-pdm-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/pdm.lock" + } + ] + }, + { + "bom-ref": "pkg:pypi/urllib3@2.1.0?package-id=0479d648f8aa7541", + "type": "library", + "name": "urllib3", + "version": "2.1.0", + "cpe": "cpe:2.3:a:python:urllib3:2.1.0:*:*:*:*:*:*:*", + "purl": "pkg:pypi/urllib3@2.1.0", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "python-package-cataloger" + }, + { + "name": "syft:package:language", + "value": "python" + }, + { + "name": "syft:package:type", + "value": "python" + }, + { + "name": "syft:package:metadataType", + "value": "python-pdm-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/pdm.lock" + } + ] + }, + { + "bom-ref": "2b9d7aa4cee17183", + "type": "file", + "name": "pdm/pdm.lock", + "hashes": [ + { + "alg": "SHA-1", + "content": "6bf2a56cff65829ecf4f977a4317fddae2a2fa4f" + }, + { + "alg": "SHA-256", + "content": "e7cb9f4512f9d1f2e047d4f4b8f28047f6ad0a3d5df7b647731a70cdd7d77d1b" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/requests@2.31.0?package-id=d97d1ee1c7a2d435", + "dependsOn": [ + "pkg:pypi/urllib3@2.1.0?package-id=0479d648f8aa7541" + ] + } + ] +} diff --git a/testdata/cyclonedx/syft-lock/pnpm9.cdx.json b/testdata/cyclonedx/syft-lock/pnpm9.cdx.json new file mode 100644 index 0000000..7e5f28a --- /dev/null +++ b/testdata/cyclonedx/syft-lock/pnpm9.cdx.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "version": 1, + "metadata": { + "timestamp": "2026-10-30T00:00:00Z", + "tools": { + "components": [ + { + "type": "application", + "author": "anchore", + "name": "syft", + "version": "1.55.0" + } + ] + }, + "component": { + "bom-ref": "7a911b482d938b9b", + "type": "file", + "name": "pnpm9" + } + }, + "components": [ + { + "bom-ref": "pkg:npm/accepts@1.3.8?package-id=67cae3e45f56cc09", + "type": "library", + "name": "accepts", + "version": "1.3.8", + "cpe": "cpe:2.3:a:accepts:accepts:1.3.8:*:*:*:*:*:*:*", + "purl": "pkg:npm/accepts@1.3.8", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "javascript-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "javascript" + }, + { + "name": "syft:package:type", + "value": "npm" + }, + { + "name": "syft:package:metadataType", + "value": "javascript-pnpm-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/pnpm-lock.yaml" + } + ] + }, + { + "bom-ref": "pkg:npm/express@4.18.2?package-id=4edbc0b4eb81a972", + "type": "library", + "name": "express", + "version": "4.18.2", + "cpe": "cpe:2.3:a:openjsf:express:4.18.2:*:*:*:*:node.js:*:*", + "purl": "pkg:npm/express@4.18.2", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "javascript-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "javascript" + }, + { + "name": "syft:package:type", + "value": "npm" + }, + { + "name": "syft:package:metadataType", + "value": "javascript-pnpm-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/pnpm-lock.yaml" + } + ] + }, + { + "bom-ref": "c895fb8e872e275f", + "type": "file", + "name": "pnpm9/pnpm-lock.yaml", + "hashes": [ + { + "alg": "SHA-1", + "content": "3e78d60ae2bd41cbc94df2aff5c2d07f3413e1e8" + }, + { + "alg": "SHA-256", + "content": "c793289aec36b1e18cf298c774e621991c3810ad6c263c65adaa3aba281e2554" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/express@4.18.2?package-id=4edbc0b4eb81a972", + "dependsOn": [ + "pkg:npm/accepts@1.3.8?package-id=67cae3e45f56cc09" + ] + } + ] +} diff --git a/testdata/cyclonedx/syft-lock/poetry.cdx.json b/testdata/cyclonedx/syft-lock/poetry.cdx.json new file mode 100644 index 0000000..5b02e70 --- /dev/null +++ b/testdata/cyclonedx/syft-lock/poetry.cdx.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "version": 1, + "metadata": { + "timestamp": "2026-10-30T00:00:00Z", + "tools": { + "components": [ + { + "type": "application", + "author": "anchore", + "name": "syft", + "version": "1.55.0" + } + ] + }, + "component": { + "bom-ref": "3e4c549a49d8fe16", + "type": "file", + "name": "poetry" + } + }, + "components": [ + { + "bom-ref": "pkg:pypi/requests@2.31.0?package-id=9082330c5423f680", + "type": "library", + "name": "requests", + "version": "2.31.0", + "cpe": "cpe:2.3:a:python:requests:2.31.0:*:*:*:*:*:*:*", + "purl": "pkg:pypi/requests@2.31.0", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "python-package-cataloger" + }, + { + "name": "syft:package:language", + "value": "python" + }, + { + "name": "syft:package:type", + "value": "python" + }, + { + "name": "syft:package:metadataType", + "value": "python-poetry-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/poetry.lock" + } + ] + }, + { + "bom-ref": "pkg:pypi/urllib3@2.1.0?package-id=9d0487ec0b086dd6", + "type": "library", + "name": "urllib3", + "version": "2.1.0", + "cpe": "cpe:2.3:a:python:urllib3:2.1.0:*:*:*:*:*:*:*", + "purl": "pkg:pypi/urllib3@2.1.0", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "python-package-cataloger" + }, + { + "name": "syft:package:language", + "value": "python" + }, + { + "name": "syft:package:type", + "value": "python" + }, + { + "name": "syft:package:metadataType", + "value": "python-poetry-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/poetry.lock" + } + ] + }, + { + "bom-ref": "4490038dd0eaaf1f", + "type": "file", + "name": "poetry/poetry.lock", + "hashes": [ + { + "alg": "SHA-1", + "content": "17cac89e54b2bb92a8f9211b5581c1ce22b0dfd3" + }, + { + "alg": "SHA-256", + "content": "83c06cc1183481c13578217219eb22f778efd9e82d7b58e8c9d9302aa36f8467" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/requests@2.31.0?package-id=9082330c5423f680", + "dependsOn": [ + "pkg:pypi/urllib3@2.1.0?package-id=9d0487ec0b086dd6" + ] + } + ] +} diff --git a/testdata/cyclonedx/syft-lock/uv.cdx.json b/testdata/cyclonedx/syft-lock/uv.cdx.json new file mode 100644 index 0000000..0f60387 --- /dev/null +++ b/testdata/cyclonedx/syft-lock/uv.cdx.json @@ -0,0 +1,189 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "version": 1, + "metadata": { + "timestamp": "2026-10-30T00:00:00Z", + "tools": { + "components": [ + { + "type": "application", + "author": "anchore", + "name": "syft", + "version": "1.55.0" + } + ] + }, + "component": { + "bom-ref": "08325307b4eb15a8", + "type": "file", + "name": "uv" + } + }, + "components": [ + { + "bom-ref": "pkg:pypi/app@0.1.0?package-id=6e016116bed81381", + "type": "library", + "name": "app", + "version": "0.1.0", + "cpe": "cpe:2.3:a:python-app:python-app:0.1.0:*:*:*:*:*:*:*", + "purl": "pkg:pypi/app@0.1.0", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "python-package-cataloger" + }, + { + "name": "syft:package:language", + "value": "python" + }, + { + "name": "syft:package:type", + "value": "python" + }, + { + "name": "syft:package:metadataType", + "value": "python-uv-lock-entry" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python-app:python_app:0.1.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python_app:python-app:0.1.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python_app:python_app:0.1.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python:python-app:0.1.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python:python_app:0.1.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:app:python-app:0.1.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:app:python_app:0.1.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python-app:app:0.1.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python_app:app:0.1.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python:app:0.1.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:app:app:0.1.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:location:0:path", + "value": "/uv.lock" + } + ] + }, + { + "bom-ref": "pkg:pypi/requests@2.31.0?package-id=2afe8646f430114f", + "type": "library", + "name": "requests", + "version": "2.31.0", + "cpe": "cpe:2.3:a:python:requests:2.31.0:*:*:*:*:*:*:*", + "purl": "pkg:pypi/requests@2.31.0", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "python-package-cataloger" + }, + { + "name": "syft:package:language", + "value": "python" + }, + { + "name": "syft:package:type", + "value": "python" + }, + { + "name": "syft:package:metadataType", + "value": "python-uv-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/uv.lock" + } + ] + }, + { + "bom-ref": "pkg:pypi/urllib3@2.1.0?package-id=075fcf4a5d5325d1", + "type": "library", + "name": "urllib3", + "version": "2.1.0", + "cpe": "cpe:2.3:a:python:urllib3:2.1.0:*:*:*:*:*:*:*", + "purl": "pkg:pypi/urllib3@2.1.0", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "python-package-cataloger" + }, + { + "name": "syft:package:language", + "value": "python" + }, + { + "name": "syft:package:type", + "value": "python" + }, + { + "name": "syft:package:metadataType", + "value": "python-uv-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/uv.lock" + } + ] + }, + { + "bom-ref": "3717ae13dd3eb744", + "type": "file", + "name": "uv/uv.lock", + "hashes": [ + { + "alg": "SHA-1", + "content": "b5474082928bfe15e3e51b6ac6a4c30c168cfbb0" + }, + { + "alg": "SHA-256", + "content": "b8cb9de778427354ad5ed9e75092034fde14bcc9a009edd2770537a75c59fd6f" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/app@0.1.0?package-id=6e016116bed81381", + "dependsOn": [ + "pkg:pypi/requests@2.31.0?package-id=2afe8646f430114f" + ] + }, + { + "ref": "pkg:pypi/requests@2.31.0?package-id=2afe8646f430114f", + "dependsOn": [ + "pkg:pypi/urllib3@2.1.0?package-id=075fcf4a5d5325d1" + ] + } + ] +} diff --git a/testdata/cyclonedx/syft-lock/yarn.cdx.json b/testdata/cyclonedx/syft-lock/yarn.cdx.json new file mode 100644 index 0000000..f34a774 --- /dev/null +++ b/testdata/cyclonedx/syft-lock/yarn.cdx.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "version": 1, + "metadata": { + "timestamp": "2026-10-30T00:00:00Z", + "tools": { + "components": [ + { + "type": "application", + "author": "anchore", + "name": "syft", + "version": "1.55.0" + } + ] + }, + "component": { + "bom-ref": "60b26b7e873ded8f", + "type": "file", + "name": "yarn" + } + }, + "components": [ + { + "bom-ref": "pkg:npm/accepts@1.3.8?package-id=16547953eed403c0", + "type": "library", + "name": "accepts", + "version": "1.3.8", + "cpe": "cpe:2.3:a:accepts:accepts:1.3.8:*:*:*:*:*:*:*", + "purl": "pkg:npm/accepts@1.3.8", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "javascript-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "javascript" + }, + { + "name": "syft:package:type", + "value": "npm" + }, + { + "name": "syft:package:metadataType", + "value": "javascript-yarn-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/yarn.lock" + } + ] + }, + { + "bom-ref": "pkg:npm/express@4.18.2?package-id=6c046d5d80ebc406", + "type": "library", + "name": "express", + "version": "4.18.2", + "cpe": "cpe:2.3:a:openjsf:express:4.18.2:*:*:*:*:node.js:*:*", + "purl": "pkg:npm/express@4.18.2", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "javascript-lock-cataloger" + }, + { + "name": "syft:package:language", + "value": "javascript" + }, + { + "name": "syft:package:type", + "value": "npm" + }, + { + "name": "syft:package:metadataType", + "value": "javascript-yarn-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/yarn.lock" + } + ] + }, + { + "bom-ref": "dcee26e98fae7b36", + "type": "file", + "name": "yarn/yarn.lock", + "hashes": [ + { + "alg": "SHA-1", + "content": "dedc73362908644f427cffb952d559bdeeaf0429" + }, + { + "alg": "SHA-256", + "content": "22f8183fd74d8b014155173f3efd3d7ae2aaaae9e210b76972148890cc0c5b6d" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/express@4.18.2?package-id=6c046d5d80ebc406", + "dependsOn": [ + "pkg:npm/accepts@1.3.8?package-id=16547953eed403c0" + ] + } + ] +} From 59ae49037e67b95296098aeb8844a577addfc1d9 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Fri, 30 Oct 2026 19:54:14 +0100 Subject: [PATCH 2/5] Read syft's SPDX dependency edges and lock file for scope syft writes SPDX dependency edges as DEPENDENCY_OF, which the parser kept as they were, so ClassifyScope found no graph. The SPDX parsers now normalise "B DEPENDENCY_OF A" to "A DEPENDS_ON B", as the README already said they normalise dependency edges. PackageSourceInfo is kept as Package.SourceInfo, and the lock file detection reads the path at its end when there is no syft location property, so it works for syft's SPDX output too. It now goes by the lock file's name for both formats rather than syft's metadata type. --- README.md | 4 +- sbom.go | 10 +- scope_lock.go | 56 ++++-- scope_test.go | 77 ++++---- spdx.go | 17 +- spdx_tagvalue.go | 2 + spdx_test.go | 29 +++ testdata/spdx/syft-lock/berry.spdx.json | 174 +++++++++++++++++ testdata/spdx/syft-lock/cargo.spdx.json | 214 +++++++++++++++++++++ testdata/spdx/syft-lock/npm.spdx.json | 174 +++++++++++++++++ testdata/spdx/syft-lock/pdm.spdx.json | 134 +++++++++++++ testdata/spdx/syft-lock/pnpm9.spdx.json | 134 +++++++++++++ testdata/spdx/syft-lock/poetry.spdx.json | 134 +++++++++++++ testdata/spdx/syft-lock/uv.spdx.json | 229 +++++++++++++++++++++++ testdata/spdx/syft-lock/yarn.spdx.json | 134 +++++++++++++ 15 files changed, 1463 insertions(+), 59 deletions(-) create mode 100644 testdata/spdx/syft-lock/berry.spdx.json create mode 100644 testdata/spdx/syft-lock/cargo.spdx.json create mode 100644 testdata/spdx/syft-lock/npm.spdx.json create mode 100644 testdata/spdx/syft-lock/pdm.spdx.json create mode 100644 testdata/spdx/syft-lock/pnpm9.spdx.json create mode 100644 testdata/spdx/syft-lock/poetry.spdx.json create mode 100644 testdata/spdx/syft-lock/uv.spdx.json create mode 100644 testdata/spdx/syft-lock/yarn.spdx.json diff --git a/README.md b/README.md index 785b3e2..f79f7fb 100644 --- a/README.md +++ b/README.md @@ -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) @@ -91,7 +91,7 @@ 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`, those are classified as direct instead. Other generators' lock-file SBOMs that don't link the project may still have top-level dependencies inferred as roots. +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. 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 them, so a caller can tell the project apart from an inferred root: diff --git a/sbom.go b/sbom.go index 89c126c..081d11a 100644 --- a/sbom.go +++ b/sbom.go @@ -120,6 +120,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 @@ -165,13 +168,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" diff --git a/scope_lock.go b/scope_lock.go index 1fceb0c..1f16693 100644 --- a/scope_lock.go +++ b/scope_lock.go @@ -1,26 +1,45 @@ package sbom -// syft records which lock file entry a package came from in the -// syft:package:metadataType property. 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 projectlessLockEntries = map[string]bool{ - "python-poetry-lock-entry": true, - "python-pdm-lock-entry": true, - "javascript-pnpm-lock-entry": true, - "javascript-yarn-lock-entry": true, // yarn v1; see yarnBerryWorkspaces +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 ( - syftMetadataType = "syft:package:metadataType" + // 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 among candidates that are packages // from a lock file that leaves the project out, so are dependencies of the // project rather than the project itself. @@ -32,15 +51,16 @@ func (s *SBOM) topLevelDependencies(candidates map[string]bool) map[string]bool if !candidates[p.ID] { continue } - kind := p.property(syftMetadataType) - if !projectlessLockEntries[kind] { + lock := p.lockFile() + name := path.Base(lock) + if lock == "" || !projectlessLockFiles[name] { continue } - if kind == "javascript-yarn-lock-entry" { + if name == "yarn.lock" { if berryLocks == nil { berryLocks = s.yarnBerryWorkspaces() } - if berryLocks[p.property(syftLocationPath)] { + if berryLocks[lock] { continue } } @@ -58,8 +78,8 @@ func (s *SBOM) yarnBerryWorkspaces() map[string]bool { locks := map[string]bool{} for i := range s.Packages { p := &s.Packages[i] - if p.Version == yarnBerryWorkspaceVersion && p.property(syftMetadataType) == "javascript-yarn-lock-entry" { - locks[p.property(syftLocationPath)] = true + if lock := p.lockFile(); p.Version == yarnBerryWorkspaceVersion && path.Base(lock) == "yarn.lock" { + locks[lock] = true } } return locks diff --git a/scope_test.go b/scope_test.go index 3c01b73..1d5cf61 100644 --- a/scope_test.go +++ b/scope_test.go @@ -2,10 +2,12 @@ package sbom import ( "bytes" + "fmt" "maps" "os" "path/filepath" "slices" + "strings" "testing" ) @@ -249,37 +251,48 @@ func TestClassifyScopeSyftLockFiles(t *testing.T) { {"yarn", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{""}}, } for _, tt := range tests { - t.Run(tt.file, func(t *testing.T) { - data, err := os.ReadFile(filepath.Join("testdata", "cyclonedx", "syft-lock", tt.file+".cdx.json")) - 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 - } - got := map[string]string{} - for id, sc := range doc.ClassifyScope() { - got[names[id]] = sc - } - if !maps.Equal(got, tt.want) { - t.Errorf("scope = %v, want %v", got, tt.want) - } - // The scanned directory, which syft names in metadata.component - // without edges, is a root too; it has no package entry. - ids, declared := doc.Roots() - var roots []string - for _, id := range ids { - roots = append(roots, names[id]) - } - slices.Sort(roots) - if declared || !slices.Equal(roots, tt.roots) { - t.Errorf("Roots() = %v, %v; want %v, false", roots, declared, tt.roots) - } - }) + for _, format := range []string{"cyclonedx/syft-lock/%s.cdx.json", "spdx/syft-lock/%s.spdx.json"} { + t.Run(fmt.Sprintf(format, tt.file), func(t *testing.T) { + testSyftLockScope(t, filepath.Join("testdata", fmt.Sprintf(format, tt.file)), tt.want, tt.roots) + }) + } + } +} + +func testSyftLockScope(t *testing.T, file string, want map[string]string, wantRoots []string) { + t.Helper() + data, err := os.ReadFile(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 { + // The scanned directory is a package in SPDX; in CycloneDX it + // is metadata.component, without a package entry. + if !strings.HasPrefix(p.ID, "SPDXRef-DocumentRoot-") { + names[p.ID] = p.Name + } + } + got := map[string]string{} + for id, sc := range doc.ClassifyScope() { + got[names[id]] = sc + } + if !maps.Equal(got, want) { + t.Errorf("scope = %v, want %v", got, want) + } + // The scanned directory, which syft declares as the root without + // edges, is a root too and shows as "". + ids, declared := doc.Roots() + var roots []string + for _, id := range ids { + roots = append(roots, names[id]) + } + slices.Sort(roots) + if declared || !slices.Equal(roots, wantRoots) { + t.Errorf("Roots() = %v, %v; want %v, false", roots, declared, wantRoots) } } diff --git a/spdx.go b/spdx.go index 8de7f92..05e60b3 100644 --- a/spdx.go +++ b/spdx.go @@ -49,6 +49,7 @@ type spdxPackage struct { Supplier string `json:"supplier,omitempty"` Originator string `json:"originator,omitempty"` PrimaryPackagePurpose string `json:"primaryPackagePurpose,omitempty"` + SourceInfo string `json:"sourceInfo,omitempty"` Checksums []spdxChecksum `json:"checksums,omitempty"` ExternalRefs []spdxExtRef `json:"externalRefs,omitempty"` } @@ -118,6 +119,7 @@ func normalizeSPDX(doc *spdxDoc) *SBOM { Homepage: sp.Homepage, DownloadLocation: sp.DownloadLocation, Filename: sp.PackageFileName, + SourceInfo: sp.SourceInfo, LicenseConcluded: sp.LicenseConcluded, LicenseDeclared: sp.LicenseDeclared, Copyright: sp.CopyrightText, @@ -148,12 +150,17 @@ func normalizeSPDX(doc *spdxDoc) *SBOM { for i := range doc.Relationships { r := &doc.Relationships[i] + source, target, typ := r.SPDXElementID, r.RelatedSPDXElement, r.RelationshipType + if typ == spdxDependencyOf { + // "B DEPENDENCY_OF A" is "A DEPENDS_ON B". + source, target, typ = target, source, RelDependsOn + } s.Relationships = append(s.Relationships, Relationship{ - SourceID: r.SPDXElementID, - Source: elements[r.SPDXElementID], - TargetID: r.RelatedSPDXElement, - Target: elements[r.RelatedSPDXElement], - Type: r.RelationshipType, + SourceID: source, + Source: elements[source], + TargetID: target, + Target: elements[target], + Type: typ, }) } diff --git a/spdx_tagvalue.go b/spdx_tagvalue.go index b848338..cb52718 100644 --- a/spdx_tagvalue.go +++ b/spdx_tagvalue.go @@ -173,6 +173,8 @@ func (p *spdxTagParser) packageField(tag, value string) error { p.pkg.CopyrightText = value case "PackageDescription": p.pkg.Description = value + case "PackageSourceInfo": + p.pkg.SourceInfo = value case "PrimaryPackagePurpose": p.pkg.PrimaryPackagePurpose = value case "PackageChecksum": diff --git a/spdx_test.go b/spdx_test.go index 0527d45..8bc237b 100644 --- a/spdx_test.go +++ b/spdx_test.go @@ -169,3 +169,32 @@ func TestSPDXEnvelopeDepthLimit(t *testing.T) { t.Fatalf("Parse = %v, want ErrUnrecognized", err) } } + +func TestSPDXDependencyOfNormalised(t *testing.T) { + inputs := map[string]string{ + "json": `{"spdxVersion": "SPDX-2.3", "SPDXID": "SPDXRef-DOCUMENT", + "packages": [ + {"SPDXID": "SPDXRef-a", "name": "a", "sourceInfo": "acquired package info from installed python package manifest file: /poetry.lock"}, + {"SPDXID": "SPDXRef-b", "name": "b"}], + "relationships": [{"spdxElementId": "SPDXRef-b", "relationshipType": "DEPENDENCY_OF", "relatedSpdxElement": "SPDXRef-a"}]}`, + "tag-value": "SPDXVersion: SPDX-2.3\nSPDXID: SPDXRef-DOCUMENT\n\n" + + "PackageName: a\nSPDXID: SPDXRef-a\nPackageSourceInfo: acquired package info from installed python package manifest file: /poetry.lock\n\n" + + "PackageName: b\nSPDXID: SPDXRef-b\n\n" + + "Relationship: SPDXRef-b DEPENDENCY_OF SPDXRef-a\n", + } + for name, input := range inputs { + t.Run(name, func(t *testing.T) { + doc, err := Parse([]byte(input)) + if err != nil { + t.Fatal(err) + } + want := Relationship{SourceID: "SPDXRef-a", Source: "a", TargetID: "SPDXRef-b", Target: "b", Type: RelDependsOn} + if len(doc.Relationships) != 1 || doc.Relationships[0] != want { + t.Errorf("Relationships = %+v, want [%+v]", doc.Relationships, want) + } + if got := doc.Packages[0].SourceInfo; got != "acquired package info from installed python package manifest file: /poetry.lock" { + t.Errorf("SourceInfo = %q", got) + } + }) + } +} diff --git a/testdata/spdx/syft-lock/berry.spdx.json b/testdata/spdx/syft-lock/berry.spdx.json new file mode 100644 index 0000000..820ce40 --- /dev/null +++ b/testdata/spdx/syft-lock/berry.spdx.json @@ -0,0 +1,174 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "berry", + "documentNamespace": "https://anchore.com/syft/dir/berry", + "creationInfo": { + "licenseListVersion": "3.29", + "creators": [ + "Organization: Anchore, Inc", + "Tool: syft-1.55.0" + ], + "created": "2026-10-30T00:00:00Z" + }, + "packages": [ + { + "name": "accepts", + "SPDXID": "SPDXRef-Package-npm-accepts-405ed17ac849bb64", + "versionInfo": "1.3.8", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed node module manifest file: /yarn.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:accepts:accepts:1.3.8:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/accepts@1.3.8" + } + ] + }, + { + "name": "app", + "SPDXID": "SPDXRef-Package-npm-app-0d57075a66eeffef", + "versionInfo": "0.0.0-use.local", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed node module manifest file: /yarn.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:app:app:0.0.0-use.local:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/app@0.0.0-use.local" + } + ] + }, + { + "name": "express", + "SPDXID": "SPDXRef-Package-npm-express-c0b33931be4c6bc1", + "versionInfo": "4.18.2", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed node module manifest file: /yarn.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:openjsf:express:4.18.2:*:*:*:*:node.js:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/express@4.18.2" + } + ] + }, + { + "name": "berry", + "SPDXID": "SPDXRef-DocumentRoot-Directory-berry", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "FILE" + } + ], + "files": [ + { + "fileName": "yarn.lock", + "SPDXID": "SPDXRef-File-yarn.lock-dcee26e98fae7b36", + "fileTypes": [ + "TEXT" + ], + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "25106461e83f05874e95ae1ff4354d48e8d90c05" + }, + { + "algorithm": "SHA256", + "checksumValue": "391d34344bad7be85707d66eb6c3de76ba8f16d30f37490af7bdcb31c3b02d6d" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseInfoInFiles": [ + "NOASSERTION" + ], + "copyrightText": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-Package-npm-accepts-405ed17ac849bb64", + "relatedSpdxElement": "SPDXRef-Package-npm-express-c0b33931be4c6bc1", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-npm-express-c0b33931be4c6bc1", + "relatedSpdxElement": "SPDXRef-Package-npm-app-0d57075a66eeffef", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-npm-app-0d57075a66eeffef", + "relatedSpdxElement": "SPDXRef-File-yarn.lock-dcee26e98fae7b36", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-npm-accepts-405ed17ac849bb64", + "relatedSpdxElement": "SPDXRef-File-yarn.lock-dcee26e98fae7b36", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-npm-express-c0b33931be4c6bc1", + "relatedSpdxElement": "SPDXRef-File-yarn.lock-dcee26e98fae7b36", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-berry", + "relatedSpdxElement": "SPDXRef-Package-npm-accepts-405ed17ac849bb64", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-berry", + "relatedSpdxElement": "SPDXRef-Package-npm-app-0d57075a66eeffef", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-berry", + "relatedSpdxElement": "SPDXRef-Package-npm-express-c0b33931be4c6bc1", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-DocumentRoot-Directory-berry", + "relationshipType": "DESCRIBES" + } + ] +} diff --git a/testdata/spdx/syft-lock/cargo.spdx.json b/testdata/spdx/syft-lock/cargo.spdx.json new file mode 100644 index 0000000..9dfaf08 --- /dev/null +++ b/testdata/spdx/syft-lock/cargo.spdx.json @@ -0,0 +1,214 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "cargo", + "documentNamespace": "https://anchore.com/syft/dir/cargo", + "creationInfo": { + "licenseListVersion": "3.29", + "creators": [ + "Organization: Anchore, Inc", + "Tool: syft-1.55.0" + ], + "created": "2026-10-30T00:00:00Z" + }, + "packages": [ + { + "name": "app", + "SPDXID": "SPDXRef-Package-rust-crate-app-728030f2ab43f725", + "versionInfo": "0.1.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from rust cargo manifest: /Cargo.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:app_project:app:0.1.0:*:*:*:*:rust:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:app:app:0.1.0:*:*:*:*:rust:*:*" + } + ] + }, + { + "name": "serde", + "SPDXID": "SPDXRef-Package-rust-crate-serde-9981dc59cb3e1cb9", + "versionInfo": "1.0.190", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from rust cargo manifest: /Cargo.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:serde_project:serde:1.0.190:*:*:*:*:rust:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:serde:serde:1.0.190:*:*:*:*:rust:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:cargo/serde@1.0.190" + } + ] + }, + { + "name": "serde_derive", + "SPDXID": "SPDXRef-Package-rust-crate-serde-derive-339127215a83e280", + "versionInfo": "1.0.190", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from rust cargo manifest: /Cargo.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:serde_derive_project:serde-derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:serde_derive_project:serde_derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:serde-derive:serde-derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:serde-derive:serde_derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:serde_derive:serde-derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:serde_derive:serde_derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:serde:serde-derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:serde:serde_derive:1.0.190:*:*:*:*:rust:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:cargo/serde_derive@1.0.190" + } + ] + }, + { + "name": "cargo", + "SPDXID": "SPDXRef-DocumentRoot-Directory-cargo", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "FILE" + } + ], + "files": [ + { + "fileName": "Cargo.lock", + "SPDXID": "SPDXRef-File-Cargo.lock-c6bea2c24af05bc1", + "fileTypes": [ + "TEXT" + ], + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "df758add487e58b2c5457b120f531158bb96b89d" + }, + { + "algorithm": "SHA256", + "checksumValue": "8e2de07cf4632c9e09df9a44ad97ce9a402b10ad168101805ca99a5ae79fd0e1" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseInfoInFiles": [ + "NOASSERTION" + ], + "copyrightText": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-Package-rust-crate-serde-derive-339127215a83e280", + "relatedSpdxElement": "SPDXRef-Package-rust-crate-serde-9981dc59cb3e1cb9", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-rust-crate-serde-9981dc59cb3e1cb9", + "relatedSpdxElement": "SPDXRef-Package-rust-crate-app-728030f2ab43f725", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-rust-crate-serde-derive-339127215a83e280", + "relatedSpdxElement": "SPDXRef-File-Cargo.lock-c6bea2c24af05bc1", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-rust-crate-app-728030f2ab43f725", + "relatedSpdxElement": "SPDXRef-File-Cargo.lock-c6bea2c24af05bc1", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-rust-crate-serde-9981dc59cb3e1cb9", + "relatedSpdxElement": "SPDXRef-File-Cargo.lock-c6bea2c24af05bc1", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-cargo", + "relatedSpdxElement": "SPDXRef-Package-rust-crate-app-728030f2ab43f725", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-cargo", + "relatedSpdxElement": "SPDXRef-Package-rust-crate-serde-9981dc59cb3e1cb9", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-cargo", + "relatedSpdxElement": "SPDXRef-Package-rust-crate-serde-derive-339127215a83e280", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-DocumentRoot-Directory-cargo", + "relationshipType": "DESCRIBES" + } + ] +} diff --git a/testdata/spdx/syft-lock/npm.spdx.json b/testdata/spdx/syft-lock/npm.spdx.json new file mode 100644 index 0000000..e85658a --- /dev/null +++ b/testdata/spdx/syft-lock/npm.spdx.json @@ -0,0 +1,174 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "npm", + "documentNamespace": "https://anchore.com/syft/dir/npm", + "creationInfo": { + "licenseListVersion": "3.29", + "creators": [ + "Organization: Anchore, Inc", + "Tool: syft-1.55.0" + ], + "created": "2026-10-30T00:00:00Z" + }, + "packages": [ + { + "name": "accepts", + "SPDXID": "SPDXRef-Package-npm-accepts-27587463afb4ef54", + "versionInfo": "1.3.8", + "supplier": "NOASSERTION", + "downloadLocation": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed node module manifest file: /package-lock.json", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:accepts:accepts:1.3.8:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/accepts@1.3.8" + } + ] + }, + { + "name": "app", + "SPDXID": "SPDXRef-Package-npm-app-d612e2e66643c207", + "versionInfo": "1.0.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed node module manifest file: /package-lock.json", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:app:app:1.0.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/app@1.0.0" + } + ] + }, + { + "name": "express", + "SPDXID": "SPDXRef-Package-npm-express-c3444b233839bfbe", + "versionInfo": "4.18.2", + "supplier": "NOASSERTION", + "downloadLocation": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed node module manifest file: /package-lock.json", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:openjsf:express:4.18.2:*:*:*:*:node.js:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/express@4.18.2" + } + ] + }, + { + "name": "npm", + "SPDXID": "SPDXRef-DocumentRoot-Directory-npm", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "FILE" + } + ], + "files": [ + { + "fileName": "package-lock.json", + "SPDXID": "SPDXRef-File-package-lock.json-fd71c2238fc07657", + "fileTypes": [ + "APPLICATION" + ], + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "cb30ec046e1244667495b6eaedaf0d69dfedcd7e" + }, + { + "algorithm": "SHA256", + "checksumValue": "80fbfd82abcabbd080a02de72c130647c1194af899ff99ab3c9997bb23d5de00" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseInfoInFiles": [ + "NOASSERTION" + ], + "copyrightText": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-Package-npm-accepts-27587463afb4ef54", + "relatedSpdxElement": "SPDXRef-Package-npm-express-c3444b233839bfbe", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-npm-express-c3444b233839bfbe", + "relatedSpdxElement": "SPDXRef-Package-npm-app-d612e2e66643c207", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-npm-accepts-27587463afb4ef54", + "relatedSpdxElement": "SPDXRef-File-package-lock.json-fd71c2238fc07657", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-npm-express-c3444b233839bfbe", + "relatedSpdxElement": "SPDXRef-File-package-lock.json-fd71c2238fc07657", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-npm-app-d612e2e66643c207", + "relatedSpdxElement": "SPDXRef-File-package-lock.json-fd71c2238fc07657", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-npm", + "relatedSpdxElement": "SPDXRef-Package-npm-accepts-27587463afb4ef54", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-npm", + "relatedSpdxElement": "SPDXRef-Package-npm-app-d612e2e66643c207", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-npm", + "relatedSpdxElement": "SPDXRef-Package-npm-express-c3444b233839bfbe", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-DocumentRoot-Directory-npm", + "relationshipType": "DESCRIBES" + } + ] +} diff --git a/testdata/spdx/syft-lock/pdm.spdx.json b/testdata/spdx/syft-lock/pdm.spdx.json new file mode 100644 index 0000000..0b345de --- /dev/null +++ b/testdata/spdx/syft-lock/pdm.spdx.json @@ -0,0 +1,134 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "pdm", + "documentNamespace": "https://anchore.com/syft/dir/pdm", + "creationInfo": { + "licenseListVersion": "3.29", + "creators": [ + "Organization: Anchore, Inc", + "Tool: syft-1.55.0" + ], + "created": "2026-10-30T00:00:00Z" + }, + "packages": [ + { + "name": "requests", + "SPDXID": "SPDXRef-Package-python-requests-d97d1ee1c7a2d435", + "versionInfo": "2.31.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed python package manifest file: /pdm.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:requests:2.31.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/requests@2.31.0" + } + ] + }, + { + "name": "urllib3", + "SPDXID": "SPDXRef-Package-python-urllib3-0479d648f8aa7541", + "versionInfo": "2.1.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed python package manifest file: /pdm.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:urllib3:2.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/urllib3@2.1.0" + } + ] + }, + { + "name": "pdm", + "SPDXID": "SPDXRef-DocumentRoot-Directory-pdm", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "FILE" + } + ], + "files": [ + { + "fileName": "pdm.lock", + "SPDXID": "SPDXRef-File-pdm.lock-2b9d7aa4cee17183", + "fileTypes": [ + "TEXT" + ], + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "6bf2a56cff65829ecf4f977a4317fddae2a2fa4f" + }, + { + "algorithm": "SHA256", + "checksumValue": "e7cb9f4512f9d1f2e047d4f4b8f28047f6ad0a3d5df7b647731a70cdd7d77d1b" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseInfoInFiles": [ + "NOASSERTION" + ], + "copyrightText": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-Package-python-urllib3-0479d648f8aa7541", + "relatedSpdxElement": "SPDXRef-Package-python-requests-d97d1ee1c7a2d435", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-python-urllib3-0479d648f8aa7541", + "relatedSpdxElement": "SPDXRef-File-pdm.lock-2b9d7aa4cee17183", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-python-requests-d97d1ee1c7a2d435", + "relatedSpdxElement": "SPDXRef-File-pdm.lock-2b9d7aa4cee17183", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-pdm", + "relatedSpdxElement": "SPDXRef-Package-python-requests-d97d1ee1c7a2d435", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-pdm", + "relatedSpdxElement": "SPDXRef-Package-python-urllib3-0479d648f8aa7541", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-DocumentRoot-Directory-pdm", + "relationshipType": "DESCRIBES" + } + ] +} diff --git a/testdata/spdx/syft-lock/pnpm9.spdx.json b/testdata/spdx/syft-lock/pnpm9.spdx.json new file mode 100644 index 0000000..d1db34f --- /dev/null +++ b/testdata/spdx/syft-lock/pnpm9.spdx.json @@ -0,0 +1,134 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "pnpm9", + "documentNamespace": "https://anchore.com/syft/dir/pnpm9", + "creationInfo": { + "licenseListVersion": "3.29", + "creators": [ + "Organization: Anchore, Inc", + "Tool: syft-1.55.0" + ], + "created": "2026-10-30T00:00:00Z" + }, + "packages": [ + { + "name": "accepts", + "SPDXID": "SPDXRef-Package-npm-accepts-67cae3e45f56cc09", + "versionInfo": "1.3.8", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed node module manifest file: /pnpm-lock.yaml", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:accepts:accepts:1.3.8:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/accepts@1.3.8" + } + ] + }, + { + "name": "express", + "SPDXID": "SPDXRef-Package-npm-express-4edbc0b4eb81a972", + "versionInfo": "4.18.2", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed node module manifest file: /pnpm-lock.yaml", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:openjsf:express:4.18.2:*:*:*:*:node.js:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/express@4.18.2" + } + ] + }, + { + "name": "pnpm9", + "SPDXID": "SPDXRef-DocumentRoot-Directory-pnpm9", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "FILE" + } + ], + "files": [ + { + "fileName": "pnpm-lock.yaml", + "SPDXID": "SPDXRef-File-pnpm-lock.yaml-c895fb8e872e275f", + "fileTypes": [ + "TEXT" + ], + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "3e78d60ae2bd41cbc94df2aff5c2d07f3413e1e8" + }, + { + "algorithm": "SHA256", + "checksumValue": "c793289aec36b1e18cf298c774e621991c3810ad6c263c65adaa3aba281e2554" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseInfoInFiles": [ + "NOASSERTION" + ], + "copyrightText": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-Package-npm-accepts-67cae3e45f56cc09", + "relatedSpdxElement": "SPDXRef-Package-npm-express-4edbc0b4eb81a972", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-npm-express-4edbc0b4eb81a972", + "relatedSpdxElement": "SPDXRef-File-pnpm-lock.yaml-c895fb8e872e275f", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-npm-accepts-67cae3e45f56cc09", + "relatedSpdxElement": "SPDXRef-File-pnpm-lock.yaml-c895fb8e872e275f", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-pnpm9", + "relatedSpdxElement": "SPDXRef-Package-npm-accepts-67cae3e45f56cc09", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-pnpm9", + "relatedSpdxElement": "SPDXRef-Package-npm-express-4edbc0b4eb81a972", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-DocumentRoot-Directory-pnpm9", + "relationshipType": "DESCRIBES" + } + ] +} diff --git a/testdata/spdx/syft-lock/poetry.spdx.json b/testdata/spdx/syft-lock/poetry.spdx.json new file mode 100644 index 0000000..7dd1c57 --- /dev/null +++ b/testdata/spdx/syft-lock/poetry.spdx.json @@ -0,0 +1,134 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "poetry", + "documentNamespace": "https://anchore.com/syft/dir/poetry", + "creationInfo": { + "licenseListVersion": "3.29", + "creators": [ + "Organization: Anchore, Inc", + "Tool: syft-1.55.0" + ], + "created": "2026-10-30T00:00:00Z" + }, + "packages": [ + { + "name": "requests", + "SPDXID": "SPDXRef-Package-python-requests-9082330c5423f680", + "versionInfo": "2.31.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed python package manifest file: /poetry.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:requests:2.31.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/requests@2.31.0" + } + ] + }, + { + "name": "urllib3", + "SPDXID": "SPDXRef-Package-python-urllib3-9d0487ec0b086dd6", + "versionInfo": "2.1.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed python package manifest file: /poetry.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:urllib3:2.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/urllib3@2.1.0" + } + ] + }, + { + "name": "poetry", + "SPDXID": "SPDXRef-DocumentRoot-Directory-poetry", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "FILE" + } + ], + "files": [ + { + "fileName": "poetry.lock", + "SPDXID": "SPDXRef-File-poetry.lock-4490038dd0eaaf1f", + "fileTypes": [ + "TEXT" + ], + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "17cac89e54b2bb92a8f9211b5581c1ce22b0dfd3" + }, + { + "algorithm": "SHA256", + "checksumValue": "83c06cc1183481c13578217219eb22f778efd9e82d7b58e8c9d9302aa36f8467" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseInfoInFiles": [ + "NOASSERTION" + ], + "copyrightText": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-Package-python-urllib3-9d0487ec0b086dd6", + "relatedSpdxElement": "SPDXRef-Package-python-requests-9082330c5423f680", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-python-requests-9082330c5423f680", + "relatedSpdxElement": "SPDXRef-File-poetry.lock-4490038dd0eaaf1f", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-python-urllib3-9d0487ec0b086dd6", + "relatedSpdxElement": "SPDXRef-File-poetry.lock-4490038dd0eaaf1f", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-poetry", + "relatedSpdxElement": "SPDXRef-Package-python-requests-9082330c5423f680", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-poetry", + "relatedSpdxElement": "SPDXRef-Package-python-urllib3-9d0487ec0b086dd6", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-DocumentRoot-Directory-poetry", + "relationshipType": "DESCRIBES" + } + ] +} diff --git a/testdata/spdx/syft-lock/uv.spdx.json b/testdata/spdx/syft-lock/uv.spdx.json new file mode 100644 index 0000000..6a3e067 --- /dev/null +++ b/testdata/spdx/syft-lock/uv.spdx.json @@ -0,0 +1,229 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "uv", + "documentNamespace": "https://anchore.com/syft/dir/uv", + "creationInfo": { + "licenseListVersion": "3.29", + "creators": [ + "Organization: Anchore, Inc", + "Tool: syft-1.55.0" + ], + "created": "2026-10-30T00:00:00Z" + }, + "packages": [ + { + "name": "app", + "SPDXID": "SPDXRef-Package-python-app-6e016116bed81381", + "versionInfo": "0.1.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed python package manifest file: /uv.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python-app:python-app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python-app:python_app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python_app:python-app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python_app:python_app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:python-app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:python_app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:app:python-app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:app:python_app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python-app:app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python_app:app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:app:app:0.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/app@0.1.0" + } + ] + }, + { + "name": "requests", + "SPDXID": "SPDXRef-Package-python-requests-2afe8646f430114f", + "versionInfo": "2.31.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed python package manifest file: /uv.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:requests:2.31.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/requests@2.31.0" + } + ] + }, + { + "name": "urllib3", + "SPDXID": "SPDXRef-Package-python-urllib3-075fcf4a5d5325d1", + "versionInfo": "2.1.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed python package manifest file: /uv.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:urllib3:2.1.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/urllib3@2.1.0" + } + ] + }, + { + "name": "uv", + "SPDXID": "SPDXRef-DocumentRoot-Directory-uv", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "FILE" + } + ], + "files": [ + { + "fileName": "uv.lock", + "SPDXID": "SPDXRef-File-uv.lock-3717ae13dd3eb744", + "fileTypes": [ + "TEXT" + ], + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "b5474082928bfe15e3e51b6ac6a4c30c168cfbb0" + }, + { + "algorithm": "SHA256", + "checksumValue": "b8cb9de778427354ad5ed9e75092034fde14bcc9a009edd2770537a75c59fd6f" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseInfoInFiles": [ + "NOASSERTION" + ], + "copyrightText": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-Package-python-urllib3-075fcf4a5d5325d1", + "relatedSpdxElement": "SPDXRef-Package-python-requests-2afe8646f430114f", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-python-requests-2afe8646f430114f", + "relatedSpdxElement": "SPDXRef-Package-python-app-6e016116bed81381", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-python-urllib3-075fcf4a5d5325d1", + "relatedSpdxElement": "SPDXRef-File-uv.lock-3717ae13dd3eb744", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-python-requests-2afe8646f430114f", + "relatedSpdxElement": "SPDXRef-File-uv.lock-3717ae13dd3eb744", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-python-app-6e016116bed81381", + "relatedSpdxElement": "SPDXRef-File-uv.lock-3717ae13dd3eb744", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-uv", + "relatedSpdxElement": "SPDXRef-Package-python-app-6e016116bed81381", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-uv", + "relatedSpdxElement": "SPDXRef-Package-python-requests-2afe8646f430114f", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-uv", + "relatedSpdxElement": "SPDXRef-Package-python-urllib3-075fcf4a5d5325d1", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-DocumentRoot-Directory-uv", + "relationshipType": "DESCRIBES" + } + ] +} diff --git a/testdata/spdx/syft-lock/yarn.spdx.json b/testdata/spdx/syft-lock/yarn.spdx.json new file mode 100644 index 0000000..34b472d --- /dev/null +++ b/testdata/spdx/syft-lock/yarn.spdx.json @@ -0,0 +1,134 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "yarn", + "documentNamespace": "https://anchore.com/syft/dir/yarn", + "creationInfo": { + "licenseListVersion": "3.29", + "creators": [ + "Organization: Anchore, Inc", + "Tool: syft-1.55.0" + ], + "created": "2026-10-30T00:00:00Z" + }, + "packages": [ + { + "name": "accepts", + "SPDXID": "SPDXRef-Package-npm-accepts-16547953eed403c0", + "versionInfo": "1.3.8", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed node module manifest file: /yarn.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:accepts:accepts:1.3.8:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/accepts@1.3.8" + } + ] + }, + { + "name": "express", + "SPDXID": "SPDXRef-Package-npm-express-6c046d5d80ebc406", + "versionInfo": "4.18.2", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed node module manifest file: /yarn.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:openjsf:express:4.18.2:*:*:*:*:node.js:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/express@4.18.2" + } + ] + }, + { + "name": "yarn", + "SPDXID": "SPDXRef-DocumentRoot-Directory-yarn", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "FILE" + } + ], + "files": [ + { + "fileName": "yarn.lock", + "SPDXID": "SPDXRef-File-yarn.lock-dcee26e98fae7b36", + "fileTypes": [ + "TEXT" + ], + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "dedc73362908644f427cffb952d559bdeeaf0429" + }, + { + "algorithm": "SHA256", + "checksumValue": "22f8183fd74d8b014155173f3efd3d7ae2aaaae9e210b76972148890cc0c5b6d" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseInfoInFiles": [ + "NOASSERTION" + ], + "copyrightText": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-Package-npm-accepts-16547953eed403c0", + "relatedSpdxElement": "SPDXRef-Package-npm-express-6c046d5d80ebc406", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-npm-accepts-16547953eed403c0", + "relatedSpdxElement": "SPDXRef-File-yarn.lock-dcee26e98fae7b36", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-npm-express-6c046d5d80ebc406", + "relatedSpdxElement": "SPDXRef-File-yarn.lock-dcee26e98fae7b36", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-yarn", + "relatedSpdxElement": "SPDXRef-Package-npm-accepts-16547953eed403c0", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-yarn", + "relatedSpdxElement": "SPDXRef-Package-npm-express-6c046d5d80ebc406", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-DocumentRoot-Directory-yarn", + "relationshipType": "DESCRIBES" + } + ] +} From 9dced53e355a8fca15a508248f507e3ec2796acc Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Sat, 31 Oct 2026 10:39:50 +0100 Subject: [PATCH 3/5] Report roots as declared when every root is declared Roots returned declared=false whenever the declared root had no edges of its own, even when no other root was inferred: an SPDX document that only DESCRIBES the app, or syft's output for a poetry, pdm, pnpm or yarn v1 lock file, where the scanned directory is the only root. The flag now says whether every returned root is declared. --- README.md | 2 +- sbom.go | 12 ++++++----- scope_test.go | 56 +++++++++++++++++++++++++++++++++++++-------------- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f79f7fb..0f9f2f3 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ for _, p := range doc.Packages { 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. 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 them, so a caller can tell the project apart from an inferred root: +`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() diff --git a/sbom.go b/sbom.go index 081d11a..55c25e8 100644 --- a/sbom.go +++ b/sbom.go @@ -16,6 +16,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "slices" "strings" ) @@ -308,8 +309,8 @@ func (s *SBOM) ClassifyScope() map[string]string { return out } -// Roots returns the IDs ClassifyScope treats as roots, and whether they are -// declared by the document rather than inferred from the graph. Declared +// 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) { @@ -327,7 +328,7 @@ func (s *SBOM) Roots() (ids []string, declared bool) { // 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 the roots are declared. +// 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{} @@ -365,13 +366,14 @@ func (s *SBOM) scopeRoots() (roots, top map[string]bool, declared bool) { } } top = s.topLevelDependencies(inferred) - roots = declaredRoots + roots = maps.Clone(declaredRoots) for id := range inferred { if !top[id] { roots[id] = true } } - return roots, top, false + declared = len(roots) > 0 && len(roots) == len(declaredRoots) + return roots, top, declared } func (s *SBOM) addPackage(p Package) { diff --git a/scope_test.go b/scope_test.go index 1d5cf61..5cfacc7 100644 --- a/scope_test.go +++ b/scope_test.go @@ -173,6 +173,29 @@ func TestClassifyScopeDeclaredRoot(t *testing.T) { t.Errorf("Roots() = %v, want [dir requests]", ids) } }) + t.Run("spdx DESCRIBES target without edges is a declared root", func(t *testing.T) { + doc, err := Parse([]byte(`{ + "spdxVersion": "SPDX-2.3", + "SPDXID": "SPDXRef-DOCUMENT", + "packages": [{"SPDXID": "SPDXRef-app", "name": "app"}], + "relationships": [{"spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-app"}] + }`)) + if err != nil { + t.Fatal(err) + } + if ids, declared := doc.Roots(); !declared || !slices.Equal(ids, []string{"SPDXRef-app"}) { + t.Errorf("Roots() = %v, %v; want [SPDXRef-app], true", ids, declared) + } + }) + t.Run("declared root without edges and inferred roots is not declared", func(t *testing.T) { + s := &SBOM{Relationships: []Relationship{ + {SourceID: "SPDXRef-DOCUMENT", TargetID: "app", Type: RelDescribes}, + {SourceID: "lib", TargetID: "dep", Type: RelDependsOn}, + }} + if ids, declared := s.Roots(); declared || !slices.Equal(ids, []string{"app", "lib"}) { + t.Errorf("Roots() = %v, %v; want [app lib], false", ids, declared) + } + }) t.Run("no graph", func(t *testing.T) { s := &SBOM{Document: Document{Component: Component{ID: "project"}}} if got := s.ClassifyScope(); got != nil { @@ -236,30 +259,33 @@ func TestClassifyScopeCycloneDXMetadataComponent(t *testing.T) { // Cargo.lock, uv.lock, yarn berry) give it a node of its own; the others // list only dependencies. func TestClassifyScopeSyftLockFiles(t *testing.T) { + // Lock files that name the project add it as an inferred root; for the + // others only the declared directory remains, so the roots are declared. tests := []struct { - file string - want map[string]string - roots []string + file string + want map[string]string + roots []string + declared bool }{ - {"npm", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{"", "app"}}, - {"cargo", map[string]string{"serde": ScopeDirect, "serde_derive": ScopeTransitive}, []string{"", "app"}}, - {"uv", map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive}, []string{"", "app"}}, - {"berry", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{"", "app"}}, - {"poetry", map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive}, []string{""}}, - {"pdm", map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive}, []string{""}}, - {"pnpm9", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{""}}, - {"yarn", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{""}}, + {"npm", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{"", "app"}, false}, + {"cargo", map[string]string{"serde": ScopeDirect, "serde_derive": ScopeTransitive}, []string{"", "app"}, false}, + {"uv", map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive}, []string{"", "app"}, false}, + {"berry", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{"", "app"}, false}, + {"poetry", map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive}, []string{""}, true}, + {"pdm", map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive}, []string{""}, true}, + {"pnpm9", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{""}, true}, + {"yarn", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{""}, true}, } for _, tt := range tests { for _, format := range []string{"cyclonedx/syft-lock/%s.cdx.json", "spdx/syft-lock/%s.spdx.json"} { t.Run(fmt.Sprintf(format, tt.file), func(t *testing.T) { - testSyftLockScope(t, filepath.Join("testdata", fmt.Sprintf(format, tt.file)), tt.want, tt.roots) + testSyftLockScope(t, filepath.Join("testdata", fmt.Sprintf(format, tt.file)), tt.want, tt.roots, tt.declared) }) } } } -func testSyftLockScope(t *testing.T, file string, want map[string]string, wantRoots []string) { +func testSyftLockScope(t *testing.T, file string, want map[string]string, wantRoots []string, wantDeclared bool) { t.Helper() data, err := os.ReadFile(file) if err != nil { @@ -292,7 +318,7 @@ func testSyftLockScope(t *testing.T, file string, want map[string]string, wantRo roots = append(roots, names[id]) } slices.Sort(roots) - if declared || !slices.Equal(roots, wantRoots) { - t.Errorf("Roots() = %v, %v; want %v, false", roots, declared, wantRoots) + if declared != wantDeclared || !slices.Equal(roots, wantRoots) { + t.Errorf("Roots() = %v, %v; want %v, %v", roots, declared, wantRoots, wantDeclared) } } From da468b1dd7cf23bffae5b7b79d02117e888234a7 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Sat, 31 Oct 2026 18:18:34 +0100 Subject: [PATCH 4/5] Classify lock entries no edge names as direct A top-level dependency without dependencies of its own appears in no DEPENDS_ON edge, so it was never inferred as a root and got no scope. In a lock file that leaves the project out, such a package is a direct dependency. It is classified so only when other packages of its lock file have edges, so a lock file the graph leaves out stays unclassified. --- README.md | 2 +- sbom.go | 6 +- scope_lock.go | 22 +- scope_test.go | 25 ++ .../cyclonedx/syft-lock/poetry-leaf.cdx.json | 183 ++++++++++++++ testdata/spdx/syft-lock/poetry-leaf.spdx.json | 224 ++++++++++++++++++ 6 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 testdata/cyclonedx/syft-lock/poetry-leaf.cdx.json create mode 100644 testdata/spdx/syft-lock/poetry-leaf.spdx.json diff --git a/README.md b/README.md index 0f9f2f3..4273aef 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ 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. Other generators' lock-file SBOMs that don't link the project may still have top-level dependencies inferred as roots. +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: diff --git a/sbom.go b/sbom.go index 55c25e8..5ade3b0 100644 --- a/sbom.go +++ b/sbom.go @@ -276,6 +276,8 @@ func (s *SBOM) FilterProperties(keep func(name string) bool) { // 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. +// So is a package from such a lock file that no edge names, 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". @@ -365,7 +367,9 @@ func (s *SBOM) scopeRoots() (roots, top map[string]bool, declared bool) { inferred[id] = true } } - top = s.topLevelDependencies(inferred) + edged := maps.Clone(sources) + maps.Copy(edged, targets) + top = s.topLevelDependencies(inferred, edged) roots = maps.Clone(declaredRoots) for id := range inferred { if !top[id] { diff --git a/scope_lock.go b/scope_lock.go index 1f16693..d8d40b0 100644 --- a/scope_lock.go +++ b/scope_lock.go @@ -40,18 +40,28 @@ func (p *Package) lockFile() string { return "" } -// topLevelDependencies returns the IDs among candidates that are packages -// from a lock file that leaves the project out, so are dependencies of the -// project rather than the project itself. -func (s *SBOM) topLevelDependencies(candidates map[string]bool) map[string]bool { +// 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] - if !candidates[p.ID] { + lock := p.lockFile() + if !inferred[p.ID] && (edged[p.ID] || !lockHasEdges[lock]) { continue } - lock := p.lockFile() name := path.Base(lock) if lock == "" || !projectlessLockFiles[name] { continue diff --git a/scope_test.go b/scope_test.go index 5cfacc7..676c4e7 100644 --- a/scope_test.go +++ b/scope_test.go @@ -275,6 +275,9 @@ func TestClassifyScopeSyftLockFiles(t *testing.T) { {"pdm", map[string]string{"requests": ScopeDirect, "urllib3": ScopeTransitive}, []string{""}, true}, {"pnpm9", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{""}, true}, {"yarn", map[string]string{"express": ScopeDirect, "accepts": ScopeTransitive}, []string{""}, true}, + // six has no dependencies and nothing depends on it, so no edge + // names it; from a lock file without the project it is direct. + {"poetry-leaf", map[string]string{"requests": ScopeDirect, "idna": ScopeTransitive, "six": ScopeDirect}, []string{""}, true}, } for _, tt := range tests { for _, format := range []string{"cyclonedx/syft-lock/%s.cdx.json", "spdx/syft-lock/%s.spdx.json"} { @@ -322,3 +325,25 @@ func testSyftLockScope(t *testing.T, file string, want map[string]string, wantRo t.Errorf("Roots() = %v, %v; want %v, %v", roots, declared, wantRoots, wantDeclared) } } + +// TestClassifyScopeLockFileWithoutEdges checks that packages of a lock file +// the graph has no edges for stay unclassified, even though no edge names +// them, while another lock file has edges. +func TestClassifyScopeLockFileWithoutEdges(t *testing.T) { + from := func(id, name, lock string) Package { + return Package{ID: id, Name: name, Properties: []Property{{Name: syftLocationPath, Value: lock}}} + } + s := &SBOM{ + Packages: []Package{ + from("app", "app", "/web/package-lock.json"), + from("express", "express", "/web/package-lock.json"), + from("requests", "requests", "/py/poetry.lock"), + from("idna", "idna", "/py/poetry.lock"), + }, + Relationships: []Relationship{{SourceID: "app", TargetID: "express", Type: RelDependsOn}}, + } + want := map[string]string{"express": ScopeDirect} + if got := s.ClassifyScope(); !maps.Equal(got, want) { + t.Errorf("scope = %v, want %v", got, want) + } +} diff --git a/testdata/cyclonedx/syft-lock/poetry-leaf.cdx.json b/testdata/cyclonedx/syft-lock/poetry-leaf.cdx.json new file mode 100644 index 0000000..9e06fe5 --- /dev/null +++ b/testdata/cyclonedx/syft-lock/poetry-leaf.cdx.json @@ -0,0 +1,183 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "version": 1, + "metadata": { + "timestamp": "2026-10-30T00:00:00Z", + "tools": { + "components": [ + { + "type": "application", + "author": "anchore", + "name": "syft", + "version": "1.55.0" + } + ] + }, + "component": { + "bom-ref": "342cf4438b70b22f", + "type": "file", + "name": "poetry-leaf" + } + }, + "components": [ + { + "bom-ref": "pkg:pypi/idna@3.7?package-id=ffe8cf1585aac85a", + "type": "library", + "name": "idna", + "version": "3.7", + "cpe": "cpe:2.3:a:kjd:internationalized_domain_names_in_applications:3.7:*:*:*:*:python:*:*", + "purl": "pkg:pypi/idna@3.7", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "python-package-cataloger" + }, + { + "name": "syft:package:language", + "value": "python" + }, + { + "name": "syft:package:type", + "value": "python" + }, + { + "name": "syft:package:metadataType", + "value": "python-poetry-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/poetry.lock" + } + ] + }, + { + "bom-ref": "pkg:pypi/requests@2.32.3?package-id=92c78c0f7c6326dd", + "type": "library", + "name": "requests", + "version": "2.32.3", + "cpe": "cpe:2.3:a:python:requests:2.32.3:*:*:*:*:*:*:*", + "purl": "pkg:pypi/requests@2.32.3", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "python-package-cataloger" + }, + { + "name": "syft:package:language", + "value": "python" + }, + { + "name": "syft:package:type", + "value": "python" + }, + { + "name": "syft:package:metadataType", + "value": "python-poetry-lock-entry" + }, + { + "name": "syft:location:0:path", + "value": "/poetry.lock" + } + ] + }, + { + "bom-ref": "pkg:pypi/six@1.16.0?package-id=7ce49a5bde83f07c", + "type": "library", + "name": "six", + "version": "1.16.0", + "cpe": "cpe:2.3:a:python-six:python-six:1.16.0:*:*:*:*:*:*:*", + "purl": "pkg:pypi/six@1.16.0", + "properties": [ + { + "name": "syft:package:foundBy", + "value": "python-package-cataloger" + }, + { + "name": "syft:package:language", + "value": "python" + }, + { + "name": "syft:package:type", + "value": "python" + }, + { + "name": "syft:package:metadataType", + "value": "python-poetry-lock-entry" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python-six:python_six:1.16.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python_six:python-six:1.16.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python_six:python_six:1.16.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python:python-six:1.16.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python:python_six:1.16.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python-six:six:1.16.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python_six:six:1.16.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:six:python-six:1.16.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:six:python_six:1.16.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:python:six:1.16.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:cpe23", + "value": "cpe:2.3:a:six:six:1.16.0:*:*:*:*:*:*:*" + }, + { + "name": "syft:location:0:path", + "value": "/poetry.lock" + } + ] + }, + { + "bom-ref": "4490038dd0eaaf1f", + "type": "file", + "name": "/private/tmp/claude-501/-Users-simon-src/147bd043-24c3-4ed4-9982-87ee6ebb9459/scratchpad/syft-fixtures/poetry-leaf/poetry.lock", + "hashes": [ + { + "alg": "SHA-1", + "content": "b68ffd690ea026643192341835a28bafc6797bb6" + }, + { + "alg": "SHA-256", + "content": "e29fca892195c674b27d379957a259df1e796dcbefa25cc251b6f3e9270f70c2" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/requests@2.32.3?package-id=92c78c0f7c6326dd", + "dependsOn": [ + "pkg:pypi/idna@3.7?package-id=ffe8cf1585aac85a" + ] + } + ] +} diff --git a/testdata/spdx/syft-lock/poetry-leaf.spdx.json b/testdata/spdx/syft-lock/poetry-leaf.spdx.json new file mode 100644 index 0000000..8231e51 --- /dev/null +++ b/testdata/spdx/syft-lock/poetry-leaf.spdx.json @@ -0,0 +1,224 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "poetry-leaf", + "documentNamespace": "https://anchore.com/syft/dir/poetry-leaf", + "creationInfo": { + "licenseListVersion": "3.29", + "creators": [ + "Organization: Anchore, Inc", + "Tool: syft-1.55.0" + ], + "created": "2026-10-30T00:00:00Z" + }, + "packages": [ + { + "name": "idna", + "SPDXID": "SPDXRef-Package-python-idna-ffe8cf1585aac85a", + "versionInfo": "3.7", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed python package manifest file: /poetry.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:kjd:internationalized_domain_names_in_applications:3.7:*:*:*:*:python:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/idna@3.7" + } + ] + }, + { + "name": "requests", + "SPDXID": "SPDXRef-Package-python-requests-92c78c0f7c6326dd", + "versionInfo": "2.32.3", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed python package manifest file: /poetry.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:requests:2.32.3:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/requests@2.32.3" + } + ] + }, + { + "name": "six", + "SPDXID": "SPDXRef-Package-python-six-7ce49a5bde83f07c", + "versionInfo": "1.16.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "sourceInfo": "acquired package info from installed python package manifest file: /poetry.lock", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python-six:python-six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python-six:python_six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python_six:python-six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python_six:python_six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:python-six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:python_six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python-six:six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python_six:six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:six:python-six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:six:python_six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:python:six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:six:six:1.16.0:*:*:*:*:*:*:*" + }, + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/six@1.16.0" + } + ] + }, + { + "name": "poetry-leaf", + "SPDXID": "SPDXRef-DocumentRoot-Directory-poetry-leaf", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "FILE" + } + ], + "files": [ + { + "fileName": "poetry.lock", + "SPDXID": "SPDXRef-File-poetry.lock-4490038dd0eaaf1f", + "fileTypes": [ + "TEXT" + ], + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "b68ffd690ea026643192341835a28bafc6797bb6" + }, + { + "algorithm": "SHA256", + "checksumValue": "e29fca892195c674b27d379957a259df1e796dcbefa25cc251b6f3e9270f70c2" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseInfoInFiles": [ + "NOASSERTION" + ], + "copyrightText": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-Package-python-idna-ffe8cf1585aac85a", + "relatedSpdxElement": "SPDXRef-Package-python-requests-92c78c0f7c6326dd", + "relationshipType": "DEPENDENCY_OF" + }, + { + "spdxElementId": "SPDXRef-Package-python-six-7ce49a5bde83f07c", + "relatedSpdxElement": "SPDXRef-File-poetry.lock-4490038dd0eaaf1f", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-python-requests-92c78c0f7c6326dd", + "relatedSpdxElement": "SPDXRef-File-poetry.lock-4490038dd0eaaf1f", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-Package-python-idna-ffe8cf1585aac85a", + "relatedSpdxElement": "SPDXRef-File-poetry.lock-4490038dd0eaaf1f", + "relationshipType": "OTHER", + "comment": "evident-by: indicates the package's existence is evident by the given file" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-poetry-leaf", + "relatedSpdxElement": "SPDXRef-Package-python-idna-ffe8cf1585aac85a", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-poetry-leaf", + "relatedSpdxElement": "SPDXRef-Package-python-requests-92c78c0f7c6326dd", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DocumentRoot-Directory-poetry-leaf", + "relatedSpdxElement": "SPDXRef-Package-python-six-7ce49a5bde83f07c", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-DocumentRoot-Directory-poetry-leaf", + "relationshipType": "DESCRIBES" + } + ] +} From 0ca40a15c1a9ec6c507f8067421387795ef35f3f Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 2 Nov 2026 10:08:38 +0000 Subject: [PATCH 5/5] Fix ClassifyScope doc on lock entries no edge names --- sbom.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sbom.go b/sbom.go index 5ade3b0..a2d918a 100644 --- a/sbom.go +++ b/sbom.go @@ -276,8 +276,8 @@ func (s *SBOM) FilterProperties(keep func(name string) bool) { // 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. -// So is a package from such a lock file that no edge names, when other -// packages of the lock file have edges. +// 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".