From 67eb449c3693002e4069261b165c34a179ff3b03 Mon Sep 17 00:00:00 2001 From: Ross-Sherlock Date: Thu, 29 Oct 2026 21:55:29 +0000 Subject: [PATCH] fix: preserve dependencies across repeated SBOM node occurrences Track visited graph nodes by pointer instead of artifact ID so distinct occurrences with different children are all traversed. Retain component deduplication and dependency merging by reference. Add regression coverage for module ordering, differing grandchildren, pointer cycles, and Maven dependency-tree JSON with module-specific exclusions. --- .../technologies/java/sbom_regression_test.go | 42 +++++++++++++++++++ utils/results/common.go | 12 +++--- utils/results/sbom_occurrences_test.go | 41 ++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 sca/bom/buildinfo/technologies/java/sbom_regression_test.go create mode 100644 utils/results/sbom_occurrences_test.go diff --git a/sca/bom/buildinfo/technologies/java/sbom_regression_test.go b/sca/bom/buildinfo/technologies/java/sbom_regression_test.go new file mode 100644 index 000000000..be8b8d7aa --- /dev/null +++ b/sca/bom/buildinfo/technologies/java/sbom_regression_test.go @@ -0,0 +1,42 @@ +package java + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-security/utils/results" + "github.com/jfrog/jfrog-cli-security/utils/techutils" + "github.com/stretchr/testify/require" +) + +// Exercise the Maven plugin JSON -> module graphs -> SBOM pipeline without a server. +func TestMavenModuleExclusionSbom(t *testing.T) { + excluded := `{"root":"example:a:1.0","nodes":{ + "example:a:1.0":{"children":["example:x:1.0"]}, + "example:x:1.0":{"children":[]}}}` + included := `{"root":"example:b:1.0","nodes":{ + "example:b:1.0":{"children":["example:x:1.0"]}, + "example:x:1.0":{"children":["example:y:1.0"]}, + "example:y:1.0":{"children":[]}}}` + for _, modules := range [][]string{{excluded, included}, {included, excluded}} { + trees, unique, _, _, err := getGraphAndPluginDepsFromDepTree(writeDepTreeModuleFiles(t, modules...)) + require.NoError(t, err) + components, dependencies := results.DepsTreeToSbom(trees...) + require.NotNil(t, components) + refs := []string{} + for _, component := range *components { + refs = append(refs, component.BOMRef) + } + expected := []string{} + for id := range unique { + expected = append(expected, techutils.XrayComponentIdToCdxComponentRef(id)) + } + require.Len(t, expected, 4) + require.ElementsMatch(t, expected, refs) + require.NotNil(t, dependencies) + edges := map[string][]string{} + for _, dep := range *dependencies { + edges[dep.Ref] = *dep.Dependencies + } + require.ElementsMatch(t, []string{techutils.XrayComponentIdToCdxComponentRef("gav://example:y:1.0")}, edges[techutils.XrayComponentIdToCdxComponentRef("gav://example:x:1.0")]) + } +} diff --git a/utils/results/common.go b/utils/results/common.go index f8d2ffcb3..8e1ee5a16 100644 --- a/utils/results/common.go +++ b/utils/results/common.go @@ -948,7 +948,9 @@ func SearchTargetResultsByRelativePath(relativeTarget string, resultsToCompare * } func DepsTreeToSbom(trees ...*xrayUtils.GraphNode) (components *[]cyclonedx.Component, dependencies *[]cyclonedx.Dependency) { - parsed := datastructures.MakeSet[string]() + // Distinct occurrences of the same component can have different children. + // Visit each node object; AppendComponents and AppendDependencies merge by ref. + parsed := datastructures.MakeSet[*xrayUtils.GraphNode]() bom := cyclonedx.NewBOM() bom.Components = &[]cyclonedx.Component{} bom.Dependencies = &[]cyclonedx.Dependency{} @@ -966,15 +968,15 @@ func DepsTreeToSbom(trees ...*xrayUtils.GraphNode) (components *[]cyclonedx.Comp return bom.Components, bom.Dependencies } -func getDataFromNode(node *xrayUtils.GraphNode, parsed *datastructures.Set[string]) (*[]cyclonedx.Component, *[]cyclonedx.Dependency) { +func getDataFromNode(node *xrayUtils.GraphNode, parsed *datastructures.Set[*xrayUtils.GraphNode]) (*[]cyclonedx.Component, *[]cyclonedx.Dependency) { bom := cyclonedx.NewBOM() bom.Components = &[]cyclonedx.Component{} bom.Dependencies = &[]cyclonedx.Dependency{} - if parsed.Exists(node.Id) { - // The node was already parsed, no need to parse it again + if parsed.Exists(node) { + // This exact node was already traversed (including shared subgraphs and cycles). return bom.Components, bom.Dependencies } - parsed.Add(node.Id) + parsed.Add(node) // Create a new component and add it to the sbom *bom.Components = append(*bom.Components, CreateScaComponentFromXrayCompId(node.Id)) if len(node.Nodes) > 0 { diff --git a/utils/results/sbom_occurrences_test.go b/utils/results/sbom_occurrences_test.go new file mode 100644 index 000000000..573754cdf --- /dev/null +++ b/utils/results/sbom_occurrences_test.go @@ -0,0 +1,41 @@ +package results + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-security/utils/techutils" + xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" + "github.com/stretchr/testify/require" +) + +func TestDepsTreeToSbomDistinctOccurrences(t *testing.T) { + node := func(name string, children ...*xrayUtils.GraphNode) *xrayUtils.GraphNode { + return &xrayUtils.GraphNode{Id: "gav://example:" + name + ":1.0", Nodes: children} + } + // The same edge occurs twice, but only its later occurrence has a grandchild. + a := node("a", node("x", node("y"))) + b := node("b", node("x", node("y", node("z")))) + c := node("c", node("x", node("w"))) + for _, roots := range [][]*xrayUtils.GraphNode{{a, b, c}, {c, b, a}, {b, a, c}} { + components, dependencies := DepsTreeToSbom(roots...) + require.NotNil(t, components) + require.Len(t, *components, 7) + edges := map[string][]string{} + for _, dep := range *dependencies { + edges[dep.Ref] = *dep.Dependencies + } + ref := func(name string) string { return techutils.XrayComponentIdToCdxComponentRef(node(name).Id) } + require.ElementsMatch(t, []string{ref("y"), ref("w")}, edges[ref("x")]) + require.ElementsMatch(t, []string{ref("z")}, edges[ref("y")]) + require.Len(t, *dependencies, 5) + } +} + +func TestDepsTreeToSbomPointerCycle(t *testing.T) { + a := &xrayUtils.GraphNode{Id: "gav://example:a:1.0"} + b := &xrayUtils.GraphNode{Id: "gav://example:b:1.0", Nodes: []*xrayUtils.GraphNode{a}} + a.Nodes = []*xrayUtils.GraphNode{b} + components, dependencies := DepsTreeToSbom(a, b) + require.Len(t, *components, 2) + require.Len(t, *dependencies, 2) +}