Skip to content

Commit 47fe340

Browse files
author
Yoan Moscatelli
committed
✨ add cyclonedx merge
1 parent 1fce4e5 commit 47fe340

File tree

11 files changed

+587
-191
lines changed

11 files changed

+587
-191
lines changed

README.md

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ using [Syft](https://github.com/anchore/syft), with optional vulnerability scann
99
## Basic Usage
1010

1111
```yaml
12-
- uses: scality/sbom@v2.0.0
12+
- uses: scality/sbom@v2
1313
with:
1414
target: /usr/local/bin
1515
```
@@ -26,17 +26,19 @@ The main [SBOM action](action.yaml) is responsible for generating SBOMs.
2626
| -------------------- | ------------------------------------------------------------------------------------------- | ------------ |
2727
| `grype-version` | Grype version to use | `0.91.0` |
2828
| `syft-version` | Syft version to use | `1.22.0` |
29-
| `target` | The target to scan (file, directory, image, ISO, or repo) | `./` |
30-
| `target-type` | Type of target to scan (file, directory, image, iso, repo) | `file` |
31-
| `output-format` | Format of the generated SBOM | `cyclonedx-json` |
29+
| `target` | The target to scan (path or image) | `./` |
30+
| `target-type` | Type of target to scan (file, directory, image, iso) | `file` |
31+
| `output-format` | Format of the generated SBOM (cyclonedx-json cyclonedx-xml github-json spdx-json spdx-tag-value syft-json syft-table syft-text template) | `cyclonedx-json` |
3232
| `output-file` | A specific file location to store the SBOM | |
3333
| `output-dir` | Directory to store generated SBOM files | `/tmp/sbom` |
3434
| `exclude-mediatypes` | Media types to exclude for images (comma-separated) | |
3535
| `distro` | Linux distribution of the target (if not auto-detected) | |
3636
| `name` | Override the detected name of the target | |
3737
| `version` | Override the detected version of the target | |
38+
| `merge` | Merge multiple SBOMs into a single file | `false` |
39+
| `merge_hierarchical` | Merge multiple SBOMs into a single hierarchical file | `false` |
3840
| `vuln` | Enable vulnerability scanning | `false` |
39-
| `vuln-output-format` | Format for the vulnerability report (HTML or JSON) when `vuln` is enabled | `json` |
41+
| `vuln-output-format` | Format for the vulnerability report when `vuln` is enabled (supports `json`, `html`, `csv`, `table`, or comma-separated values like `html,json`) | `cyclonedx-json` |
4042
| `vuln-output-file` | A specific file location to store the vulnerability report | |
4143

4244
## Example Usage
@@ -46,18 +48,32 @@ The main [SBOM action](action.yaml) is responsible for generating SBOMs.
4648
Use the `output-format` and `vuln-output-format` parameters to choose the SBOM and vulnerability report formats:
4749

4850
```yaml
49-
- uses: scality/sbom@v2.0.0
51+
- uses: scality/sbom@v2
5052
with:
5153
target: ./artifacts
5254
output-format: cyclonedx-json # SBOM format
53-
vuln: true # Enable vulnerability scanning
54-
vuln-output-format: html # Vulnerability report format
55+
vuln: true # Enable vulnerability scanning
56+
vuln-output-format: html # Generate HTML vulnerability report
57+
```
58+
59+
The HTML format provides an interactive report with a dynamic table for better visualization of vulnerabilities, allowing for easier filtering and sorting.
60+
61+
### Multiple vulnerability report formats
62+
63+
You can generate multiple formats simultaneously by using comma-separated values:
64+
65+
```yaml
66+
- uses: scality/sbom@v2
67+
with:
68+
target: ./artifacts
69+
vuln: true
70+
vuln-output-format: html,json # Generate both HTML and JSON reports
5571
```
5672

5773
### Specify target type explicitly
5874

5975
```yaml
60-
- uses: scality/sbom@v2.0.0
76+
- uses: scality/sbom@v2
6177
with:
6278
target: myimage.tar
6379
target-type: image
@@ -68,7 +84,7 @@ Use the `output-format` and `vuln-output-format` parameters to choose the SBOM a
6884
For images (like those built using Oras) that use custom mediatypes not supported by Skopeo:
6985

7086
```yaml
71-
- uses: scality/sbom@v2.0.0
87+
- uses: scality/sbom@v2
7288
with:
7389
target: ./images
7490
target-type: image
@@ -78,7 +94,7 @@ For images (like those built using Oras) that use custom mediatypes not supporte
7894
### Enable vulnerability scanning
7995

8096
```yaml
81-
- uses: scality/sbom@v2.0.0
97+
- uses: scality/sbom@v2
8298
with:
8399
target: ./
84100
vuln: true
@@ -113,7 +129,7 @@ jobs:
113129
path: ${{ env.BASE_PATH }}/repo/myrepo
114130
115131
- name: Generate SBOM for repository
116-
uses: scality/sbom@v2.0.0
132+
uses: scality/sbom@v2
117133
with:
118134
target: ${{ env.BASE_PATH }}/repo/myrepo
119135
target-type: file
@@ -130,13 +146,16 @@ jobs:
130146
curl -sSfL -o ${{ env.BASE_PATH }}/iso/my.iso -u $ARTIFACTS_USER:$ARTIFACTS_PASSWORD $ARTIFACTS_URL/my.iso
131147
132148
- name: Generate SBOM for ISO
133-
uses: scality/sbom@v2.0.0
149+
uses: scality/sbom@v2
134150
with:
135151
target: ${{ env.BASE_PATH }}/iso/my.iso
136152
target-type: iso
137153
version: "1.0.0"
138154
output-dir: ${{ env.SBOM_PATH }}
139155
vuln: true
156+
vuln-output-format: html
157+
merge: true
158+
merge_hierarchical: true
140159
141160
- name: Upload artifacts
142161
uses: actions/upload-artifact@v4
@@ -211,10 +230,6 @@ In the generated SBOM files, you will find CycloneDX metadata. Examples include:
211230
}
212231
```
213232

214-
## References
215-
216-
HTML template for **Grype** vulnerability reports was modified from [Grype Contrib](https://github.com/opt-nc/grype-contribs).
217-
218233
## Core Workflow
219234

220235
```mermaid
@@ -343,3 +358,77 @@ flowchart TD
343358

344359
1. If `vuln` is enabled, the provider’s `vuln()` method uses Grype to scan the SBOM.
345360
2. Grype generates a vulnerability report saved as: `{target_type}_{name}_{version}_vuln.json`.
361+
362+
## Merge Explanation
363+
364+
The merge is per default not hierarchical for the `components` field of a `component`. This means that components that were contained in the `components` of an already present component will just be added as new components under the SBOMs’ `components` sections. The `--hierarchical` flag allows for hierarchical merges. This affects only the top level components of the merged SBOM. The structured of nested components is preserved in both cases (except the removal of already present components), as shown for *component 4* in the image below.
365+
366+
```mermaid
367+
flowchart TD
368+
subgraph "SBOM 1"
369+
0["Component 0 <br> metadata component"]
370+
01["Component 1"]
371+
02["Component 2"]
372+
373+
0 -->|contains| 01
374+
0 -->|contains| 02
375+
end
376+
```
377+
378+
```mermaid
379+
flowchart TD
380+
subgraph "SBOM 2"
381+
1["Component 1 <br> metadata component"]
382+
13["Component 3"]
383+
14["Component 4"]
384+
15["Component 5"]
385+
386+
1 -->|contains| 13
387+
1 -->|contains| 14
388+
14 -->|contains| 15
389+
end
390+
```
391+
392+
### Default merge:
393+
394+
```mermaid
395+
flowchart TD
396+
subgraph "Merged SBOM"
397+
0["Component 0 <br> metadata component"]
398+
1["Component 1"]
399+
2["Component 2"]
400+
3["Component 3"]
401+
4["Component 4"]
402+
5["Component 5"]
403+
404+
0 -->|contains| 1
405+
0 -->|contains| 2
406+
0 -->|contains| 3
407+
0 -->|contains| 4
408+
4 -->|contains| 5
409+
end
410+
```
411+
412+
### Hierarchical merge:
413+
414+
```mermaid
415+
flowchart TD
416+
subgraph "Merged SBOM"
417+
0["Component 0 <br> metadata component"]
418+
1["Component 1"]
419+
2["Component 2"]
420+
3["Component 3"]
421+
4["Component 4"]
422+
5["Component 5"]
423+
424+
0 -->|contains| 1
425+
0 -->|contains| 2
426+
1 -->|contains| 3
427+
1 -->|contains| 4
428+
4 -->|contains| 5
429+
end
430+
```
431+
432+
### References
433+
- [CycloneDX Specification](https://cyclonedx.org/docs/1.6/json/)
434+
- [CycloneDX Merge](https://festo-se.github.io/cyclonedx-editor-validator/usage/merge.html)

action.yaml

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,10 @@ inputs:
2121
target_type:
2222
description: "The type of target to scan"
2323
required: false
24-
options:
25-
- file
26-
- image
27-
- iso
2824

2925
output_format:
3026
description: "The SBOM format to export"
3127
required: false
32-
options:
33-
- cyclonedx-json
34-
- cyclonedx-xml
35-
- spdx-json
36-
- spdx
3728

3829
output_file:
3930
description: "A file location to store the SBOM"
@@ -59,22 +50,21 @@ inputs:
5950
description: "The version of the target, if not detected"
6051
required: false
6152

53+
merge:
54+
description: "Merge generated SBOMs into a single file, only for CycloneDX"
55+
required: false
56+
57+
merge_hierarchical:
58+
description: "Merge generated SBOMs into a single file, only for CycloneDX"
59+
required: false
60+
6261
vuln:
6362
description: "Check for vulnerabilities"
6463
required: false
65-
type: boolean
6664

6765
vuln_output_format:
6866
description: "Vulnerability output format"
6967
required: false
70-
options:
71-
- json
72-
- table
73-
- sarif
74-
- cyclonedx-json
75-
- cyclonedx
76-
- html
77-
- junit
7868

7969
vuln_output_file:
8070
description: "A file location to store the vulnerability report"

src/config/inputs.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ def get_inputs():
2929
"distro": os.environ.get("INPUT_DISTRO"),
3030
"name": os.environ.get("INPUT_NAME", None),
3131
"version": os.environ.get("INPUT_VERSION", None),
32+
"merge": os.environ.get("INPUT_MERGE", "false").lower() == "true",
33+
"merge_hierarchical": os.environ.get(
34+
"INPUT_MERGE_HIERARCHICAL", "false"
35+
).lower()
36+
== "true",
3237
"vuln": os.environ.get("INPUT_VULN", "false").lower() == "true",
33-
"vuln_output_format": os.environ.get("INPUT_VULN_OUTPUT_FORMAT", "json"),
38+
"vuln_output_format": os.environ.get(
39+
"INPUT_VULN_OUTPUT_FORMAT", "cyclonedx-json"
40+
),
3441
"vuln_output_file": os.environ.get("INPUT_VULN_OUTPUT_FILE"),
3542
}

src/config/outputs.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Base scanner functionality shared between scanners."""
2+
3+
4+
def create_standard_result( # pylint: disable=too-many-arguments, too-many-positional-arguments
5+
scanner: str,
6+
success: bool = True,
7+
target: str = None,
8+
name: str = None,
9+
version: str = None,
10+
sbom_path: str = None,
11+
stdout: str = None,
12+
additional: dict = None,
13+
error: str = None,
14+
):
15+
"""
16+
Create a standardized result dictionary for scanner outputs.
17+
18+
Returns a flat JSON structure that is easy to parse.
19+
"""
20+
result = {
21+
"scanner": scanner,
22+
"success": success,
23+
"target": target,
24+
"name": name,
25+
"version": version,
26+
"sbom_path": sbom_path,
27+
}
28+
if stdout:
29+
result["stdout"] = stdout
30+
if additional and isinstance(additional, dict):
31+
result.update(additional)
32+
if error:
33+
result["error"] = error
34+
return result

src/config/templates/html.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,4 +592,4 @@
592592

593593
</body>
594594

595-
</html>
595+
</html>

src/main.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import sys
55
import click
66
from config.inputs import get_inputs
7-
from modules.install import install_scanners
7+
from modules.install import install_tool
88
from providers.factory import get_provider
99

1010
# Configure logging to show INFO level messages
@@ -29,19 +29,19 @@ def cli():
2929
def install():
3030
"""
3131
## Install the requirements.
32-
### This command is used to install the required scanners.
32+
### This command is used to install the required tools.
3333
"""
3434
click.echo("Installing requirements...")
3535
# Get the inputs from the Github action
3636
inputs = get_inputs()
3737

38-
# Filter to only include scanner versions
39-
scanner_versions = {
38+
# Filter to only include tool versions
39+
tool_versions = {
4040
"syft_version": inputs.get("syft_version"),
4141
"grype_version": inputs.get("grype_version"),
4242
}
4343

44-
install_scanners(scanner_versions)
44+
install_tool(tool_versions)
4545

4646

4747
@cli.command()
@@ -57,13 +57,29 @@ def scan():
5757
# Get the appropriate provider based on inputs
5858
provider = get_provider(inputs)
5959

60+
# Always generate the SBOM first
6061
sbom_result = provider.sbom(inputs)
6162
click.echo(f"SBOM generated: {sbom_result}")
6263

63-
# If vulnerability scanning is enabled, run the scanner
64+
# Track what we need to scan for vulnerabilities
65+
sboms_to_scan = [sbom_result]
66+
67+
# Merge if requested
68+
if inputs.get("merge"):
69+
merged_sbom = provider.merge(sbom_result)
70+
click.echo(f"SBOM merged: {merged_sbom}")
71+
# Add merged SBOM to vulnerability scan targets
72+
sboms_to_scan.append(merged_sbom)
73+
74+
# Run vulnerability scanning if enabled
6475
if inputs.get("vuln"):
65-
vuln_report = provider.vuln(sbom_result)
66-
click.echo(f"Vulnerability report generated: {vuln_report}")
76+
for sbom in sboms_to_scan:
77+
is_merged = sbom != sbom_result
78+
vuln_report = provider.vuln(sbom)
79+
sbom_type = "merged " if is_merged else ""
80+
click.echo(
81+
f"Vulnerability report generated for {sbom_type}SBOM: {vuln_report}"
82+
)
6783

6884
except (ValueError, FileNotFoundError, RuntimeError) as error:
6985
logging.error("Scan failed: %s", error)

0 commit comments

Comments
 (0)