Skip to content

Commit e363b8e

Browse files
committed
feat: add merge-boms command to combine multiple CycloneDX BOMs
1 parent 00813ce commit e363b8e

File tree

3 files changed

+310
-0
lines changed

3 files changed

+310
-0
lines changed

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,46 @@ Flags stand for:
400400
- **--infile (-f)** - input cyclonedx sbom json file. (Optional - reades from stdin when not specified)
401401
- **--outfile (-o)** - output file path to write bom json. (Optional - writes to stdout when not specified)
402402

403+
### 9.3 Merge Multiple BOMs
404+
405+
The `merge-boms` command allows you to merge multiple CycloneDX BOMs into a single consolidated BOM. This is useful when you have multiple BOMs from different components or services that need to be combined into a unified view.
406+
407+
Sample command:
408+
409+
```bash
410+
docker run --rm registry.relizahub.com/library/rearm-cli \
411+
bomutils merge-boms \
412+
--input-files bom1.json,bom2.json,bom3.json \
413+
--name "merged-application" \
414+
--version "1.0.0" \
415+
--group "com.example" \
416+
--structure FLAT \
417+
--outfile merged-bom.json
418+
```
419+
420+
Sample command with hierarchical structure:
421+
422+
```bash
423+
docker run --rm registry.relizahub.com/library/rearm-cli \
424+
bomutils merge-boms \
425+
--input-files frontend-bom.json,backend-bom.json \
426+
--name "full-stack-app" \
427+
--version "2.1.0" \
428+
--structure HIERARCHICAL \
429+
--root-component-merge-mode PRESERVE_UNDER_NEW_ROOT \
430+
--purl "pkg:generic/[email protected]"
431+
```
432+
433+
Flags stand for:
434+
- **--input-files** - Comma-separated list of input BOM file paths to merge (required)
435+
- **--name** - Name for the new root component of the merged BOM (optional)
436+
- **--version** - Version for the new root component of the merged BOM (optional)
437+
- **--group** - Group for the new root component of the merged BOM (optional)
438+
- **--structure** - Structure of the merged BOM: FLAT or HIERARCHICAL (default: FLAT)
439+
- **--root-component-merge-mode** - How to handle root components: PRESERVE_UNDER_NEW_ROOT or FLATTEN_UNDER_NEW_ROOT (default: PRESERVE_UNDER_NEW_ROOT)
440+
- **--purl** - Set bom-ref and purl for the root merged component (optional)
441+
- **--outfile** - Output file path to write merged BOM (optional - writes to stdout when not specified)
442+
403443
### 10. Use Case: Finalize Release After CI Completion
404444

405445
The `releasefinalizer` command is used to run a release finalizer, which can be executed after submitting a release or after adding a new deliverable to a release. This command signals the completion of the CI process for a release in ReARM, ensuring all post-release or post-deliverable actions are triggered.

cmd/merge_boms.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"crypto/rand"
6+
"fmt"
7+
"os"
8+
9+
cdx "github.com/CycloneDX/cyclonedx-go"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var (
14+
MergeStructure string
15+
MergeGroup string
16+
MergeName string
17+
MergeVersion string
18+
MergeRootComponentMode string
19+
MergeInputFiles []string
20+
MergePurl string
21+
MergeOutfile string
22+
)
23+
24+
var mergeBomsCmd = &cobra.Command{
25+
Use: "merge-boms",
26+
Short: "Merge multiple CycloneDX BOMs into a single BOM",
27+
Run: func(cmd *cobra.Command, args []string) {
28+
// 1. Read all BOM objects from input files
29+
var boms []*cdx.BOM
30+
for _, path := range MergeInputFiles {
31+
data, err := os.ReadFile(path)
32+
if err != nil {
33+
fmt.Printf("Error reading file %s: %v\n", path, err)
34+
os.Exit(1)
35+
}
36+
bom := readBomFromBytes(data)
37+
boms = append(boms, bom)
38+
}
39+
40+
// 2. Extract roots, components, dependencies
41+
var roots []*cdx.Component
42+
var allComponents []*cdx.Component
43+
var allDependencies []*cdx.Dependency
44+
45+
for _, bom := range boms {
46+
if bom.Metadata != nil && bom.Metadata.Component != nil {
47+
roots = append(roots, bom.Metadata.Component)
48+
}
49+
if bom.Components != nil {
50+
for _, comp := range *bom.Components {
51+
allComponents = append(allComponents, &comp)
52+
}
53+
}
54+
if bom.Dependencies != nil {
55+
for _, dep := range *bom.Dependencies {
56+
allDependencies = append(allDependencies, &dep)
57+
}
58+
}
59+
}
60+
61+
// Prepare deduplication map for FLAT mode
62+
componentMap := make(map[string]*cdx.Component) // key: bom-ref
63+
for _, comp := range allComponents {
64+
if comp.BOMRef != "" {
65+
componentMap[comp.BOMRef] = comp
66+
}
67+
}
68+
69+
// 3. Merge logic
70+
// 3.1 Metadata: create new BOM object, set root from flags
71+
mergedBOM := &cdx.BOM{
72+
BOMFormat: "CycloneDX",
73+
SpecVersion: cdx.SpecVersion1_6,
74+
SerialNumber: generateSerialNumber(),
75+
Version: 1,
76+
Metadata: &cdx.Metadata{
77+
Component: &cdx.Component{
78+
Type: cdx.ComponentTypeApplication,
79+
Name: MergeName,
80+
Group: MergeGroup,
81+
Version: MergeVersion,
82+
},
83+
},
84+
}
85+
86+
// Set merged root component BOMRef and PackageURL
87+
mergedRootRef := setMergedRootComponent(
88+
mergedBOM.Metadata.Component,
89+
MergeGroup, MergeName, MergeVersion, MergePurl,
90+
)
91+
92+
// 3.2 Components: FLAT or HIERARCHICAL
93+
if MergeStructure == "FLAT" {
94+
flatComponents := mergeFlatComponents(componentMap)
95+
mergedBOM.Components = &flatComponents
96+
} else {
97+
hierComponents := mergeHierarchicalComponents(roots, boms)
98+
mergedBOM.Components = &hierComponents
99+
}
100+
101+
// 3.3 Dependencies: merge according to root component merge mode
102+
var mergedDependencies []cdx.Dependency
103+
switch MergeRootComponentMode {
104+
case "PRESERVE_UNDER_NEW_ROOT":
105+
mergedDependencies = mergeDependenciesPreserve(roots, allDependencies, mergedRootRef)
106+
case "FLATTEN_UNDER_NEW_ROOT":
107+
mergedDependencies = mergeDependenciesFlatten(roots, allDependencies, mergedRootRef)
108+
default:
109+
fmt.Printf("Unknown MergeRootComponentMode: %s\n", MergeRootComponentMode)
110+
os.Exit(1)
111+
}
112+
if len(mergedDependencies) > 0 {
113+
mergedBOM.Dependencies = &mergedDependencies
114+
}
115+
116+
// 4. Output
117+
buf := new(bytes.Buffer)
118+
if err := cdx.NewBOMEncoder(buf, cdx.BOMFileFormatJSON).Encode(mergedBOM); err != nil {
119+
fmt.Printf("Error encoding merged BOM: %v\n", err)
120+
os.Exit(1)
121+
}
122+
if MergeOutfile == "" || MergeOutfile == "-" {
123+
_, err := os.Stdout.Write(buf.Bytes())
124+
if err != nil {
125+
fmt.Printf("Error writing to stdout: %v\n", err)
126+
os.Exit(1)
127+
}
128+
} else {
129+
if err := os.WriteFile(MergeOutfile, buf.Bytes(), 0644); err != nil {
130+
fmt.Printf("Error writing to file %s: %v\n", MergeOutfile, err)
131+
os.Exit(1)
132+
}
133+
}
134+
135+
},
136+
}
137+
138+
func init() {
139+
mergeBomsCmd.Flags().StringVar(&MergeStructure, "structure", "FLAT", "Structure of the merged BOM (FLAT, HIERARCHICAL)")
140+
mergeBomsCmd.Flags().StringVar(&MergeGroup, "group", "", "New root component group")
141+
mergeBomsCmd.Flags().StringVar(&MergeName, "name", "", "New root component name")
142+
mergeBomsCmd.Flags().StringVar(&MergeVersion, "version", "", "New root component version")
143+
mergeBomsCmd.Flags().StringVar(&MergeRootComponentMode, "root-component-merge-mode", "PRESERVE_UNDER_NEW_ROOT", "Root component merge mode (PRESERVE_UNDER_NEW_ROOT, FLATTEN_UNDER_NEW_ROOT)")
144+
mergeBomsCmd.Flags().StringSliceVar(&MergeInputFiles, "input-files", nil, "Input file paths for BOMs to merge")
145+
mergeBomsCmd.Flags().StringVar(&MergePurl, "purl", "", "Set bom-ref and purl for the root merged component")
146+
mergeBomsCmd.Flags().StringVar(&MergeOutfile, "outfile", "", "Output file path to write merged BOM (default: stdout)")
147+
148+
bomUtils.AddCommand(mergeBomsCmd)
149+
}
150+
151+
// generateSerialNumber creates a UUID-style serial number for the merged BOM
152+
func generateSerialNumber() string {
153+
b := make([]byte, 16)
154+
_, err := rand.Read(b)
155+
if err != nil {
156+
// Fallback to a simple timestamp-based serial number if random generation fails
157+
return fmt.Sprintf("urn:uuid:merged-bom-%d", len(b))
158+
}
159+
// Format as UUID v4
160+
b[6] = (b[6] & 0x0f) | 0x40 // Version 4
161+
b[8] = (b[8] & 0x3f) | 0x80 // Variant bits
162+
return fmt.Sprintf("urn:uuid:%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
163+
}

cmd/merge_boms_helpers.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
cdx "github.com/CycloneDX/cyclonedx-go"
7+
)
8+
9+
// mergeFlatComponents deduplicates and returns flat components
10+
func mergeFlatComponents(componentMap map[string]*cdx.Component) []cdx.Component {
11+
uniqueComponents := make([]cdx.Component, 0, len(componentMap))
12+
for _, comp := range componentMap {
13+
uniqueComponents = append(uniqueComponents, *comp)
14+
}
15+
return uniqueComponents
16+
}
17+
18+
// mergeHierarchicalComponents creates hierarchical components with children
19+
func mergeHierarchicalComponents(roots []*cdx.Component, boms []*cdx.BOM) []cdx.Component {
20+
hierarchicalComponents := make([]cdx.Component, 0, len(roots))
21+
for i, root := range roots {
22+
if root == nil {
23+
continue
24+
}
25+
bom := boms[i]
26+
var children []cdx.Component
27+
if bom.Components != nil {
28+
for _, comp := range *bom.Components {
29+
children = append(children, comp)
30+
}
31+
}
32+
rootCopy := *root
33+
if len(children) > 0 {
34+
rootCopy.Components = &children
35+
}
36+
hierarchicalComponents = append(hierarchicalComponents, rootCopy)
37+
}
38+
return hierarchicalComponents
39+
}
40+
41+
// mergeDependenciesPreserve creates dependencies for PRESERVE_UNDER_NEW_ROOT mode
42+
func mergeDependenciesPreserve(roots []*cdx.Component, allDependencies []*cdx.Dependency, mergedRootRef string) []cdx.Dependency {
43+
var rootRefs []string
44+
for _, root := range roots {
45+
if root != nil && root.BOMRef != "" {
46+
rootRefs = append(rootRefs, root.BOMRef)
47+
}
48+
}
49+
mergedDependencies := []cdx.Dependency{
50+
{
51+
Ref: mergedRootRef,
52+
Dependencies: &rootRefs,
53+
},
54+
}
55+
for _, dep := range allDependencies {
56+
if dep != nil {
57+
mergedDependencies = append(mergedDependencies, *dep)
58+
}
59+
}
60+
return mergedDependencies
61+
}
62+
63+
// mergeDependenciesFlatten creates dependencies for FLATTEN_UNDER_NEW_ROOT mode
64+
func mergeDependenciesFlatten(roots []*cdx.Component, allDependencies []*cdx.Dependency, mergedRootRef string) []cdx.Dependency {
65+
depMap := make(map[string]*cdx.Dependency)
66+
for _, dep := range allDependencies {
67+
if dep != nil {
68+
depMap[dep.Ref] = dep
69+
}
70+
}
71+
var flattenedDependsOn []string
72+
for _, root := range roots {
73+
if root != nil && depMap[root.BOMRef] != nil {
74+
flattenedDependsOn = append(flattenedDependsOn, (*depMap[root.BOMRef].Dependencies)...)
75+
}
76+
}
77+
mergedDependencies := []cdx.Dependency{
78+
{
79+
Ref: mergedRootRef,
80+
Dependencies: &flattenedDependsOn,
81+
},
82+
}
83+
for _, dep := range allDependencies {
84+
isRoot := false
85+
for _, root := range roots {
86+
if root != nil && dep.Ref == root.BOMRef {
87+
isRoot = true
88+
break
89+
}
90+
}
91+
if !isRoot && dep != nil {
92+
mergedDependencies = append(mergedDependencies, *dep)
93+
}
94+
}
95+
return mergedDependencies
96+
}
97+
98+
// setMergedRootComponent sets the BOMRef and PackageURL for the merged root
99+
func setMergedRootComponent(component *cdx.Component, group, name, version, purl string) string {
100+
if purl != "" {
101+
component.PackageURL = purl
102+
component.BOMRef = purl
103+
} else {
104+
component.BOMRef = fmt.Sprintf("%s/%s@%s", group, name, version)
105+
}
106+
return component.BOMRef
107+
}

0 commit comments

Comments
 (0)