Skip to content

Commit 4b3986a

Browse files
committed
feat: compare reference and target SBOMs, show extra components in target
Add a `compare` command to identify components present in the target SBOM but missing in the reference/base SBOM. Highlight these extra components and create a new SBOM containing only the extras. Help track already sent or cleared components and identify additional components that require license clearing. Signed-off-by: badrikesh prusty <badrikesh.prusty@siemens.com>
1 parent e4f1b1e commit 4b3986a

File tree

11 files changed

+524
-45
lines changed

11 files changed

+524
-45
lines changed

docs/source/commands.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ Commands
1010
commands/repack
1111
commands/export
1212
commands/merge
13+
commands/compare
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
The ``compare`` command compares two SBOMs and produces a new SBOM containing only the components
2+
that are present in the target SBOM but not in the base (reference) SBOM.
3+
4+
The most common use-case is identifying new or added components between two builds, images, or
5+
distribution states (for example, comparing a previous release SBOM against a newer one),
6+
including filtering out already license-cleared components to generate an SBOM containing only
7+
components pending license clearance.
8+
9+
The comparison is directional:
10+
11+
* Base SBOM – treated as the reference
12+
* Target SBOM – treated as the new or updated SBOM
13+
14+
Given the following structure:
15+
16+
Base SBOM
17+
18+
.. code-block::
19+
20+
base-root
21+
|- binary-dep1
22+
| |- source-dep1
23+
|- binary-dep2
24+
25+
Target SBOM
26+
27+
.. code-block::
28+
29+
target-root
30+
|- binary-dep1
31+
| |- source-dep1
32+
|- binary-dep2
33+
|- binary-dep3
34+
| |- source-dep3
35+
36+
Running compare would produce:
37+
38+
.. code-block::
39+
40+
compare-doc-root
41+
|- binary-dep3
42+
| |- source-dep3
43+
44+
Components are considered the same if they share the same PURL (Package URL). Only components
45+
that are new in the target SBOM, along with their nested dependencies, are included in the
46+
resulting SBOM.

docs/source/commands/compare.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
``compare`` command
2+
===================
3+
4+
.. include:: compare-description.inc
5+
6+
.. note::
7+
Only SBOMs of the same type can be compared. Specifying both SPDX and CDX SBOMs will cause an error.

docs/source/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
("man/debsbom-generate", "debsbom-generate", "debsbom generate command", [author], 1),
7272
("man/debsbom-merge", "debsbom-merge", "debsbom merge command", [author], 1),
7373
("man/debsbom-repack", "debsbom-repack", "debsbom repack command", [author], 1),
74+
("man/debsbom-compare", "debsbom-compare", "debsbom compare command", [author], 1),
7475
(
7576
"man/debsbom-source-merge",
7677
"debsbom-source-merge",

docs/source/examples.rst

Lines changed: 8 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -128,62 +128,25 @@ For that, provide both an SBOM, as well as a set of "to-be-processed" packages v
128128
Compare SBOMs
129129
~~~~~~~~~~~~~
130130
131-
The SBOMs produced by ``debsbom`` can be further processed with existing tools – for example, the `CycloneDX CLI <https://github.com/CycloneDX/cyclonedx-cli>`_.
132-
Comparing two SBOMs directly is outside the scope of ``debsbom``, but you can determine which components have changed by using a short snippet such as the one shown below.
133-
134-
Locate Changes
135-
^^^^^^^^^^^^^^
136-
137-
.. code-block:: bash
138-
139-
cyclonedx-cli diff --component-versions --output-format json \
140-
sbom.old.cdx.json sbom.cdx.json | \
141-
jq -r '.componentVersions[] | select(.added!=[] or .removed!=[]) | {"added": .added[0].purl, "removed": .removed[0].purl}'
142-
# {"added", "purl-a-1.1", "removed": "purl-a-1.0"}
143-
# {...}
144-
145-
A similar output can be generated by just using ``jq`` and ``diff``:
146-
147-
.. code-block:: bash
148-
149-
diff --color \
150-
<(jq -r --sort-keys '.components[].purl' sbom.old.cdx.json) \
151-
<(jq -r --sort-keys '.components[].purl' sbom.cdx.json)
131+
The :doc:`/commands/compare` compares a base (reference) SBOM with a target (new) SBOM and produces
132+
a new SBOM containing only the components present in the target. The typical use-case is identifying
133+
newly added or changed components between two builds or releases.
152134
153135
Identify new Components
154136
^^^^^^^^^^^^^^^^^^^^^^^
155137
156-
Consider you only want to know the changed and added components, e.g. for license clearing.
138+
Use ``debsbom compare`` when you only want to see changed or added components, e.g., to generate an
139+
SBOM for license clearance.
157140
158141
.. code-block:: bash
159142
160-
PURLS=$( \
161-
diff -U0 \
162-
<(jq -r --sort-keys '.components[].purl' sbom.old.cdx.json) \
163-
<(jq -r --sort-keys '.components[].purl' sbom.cdx.json) \
164-
| grep ^+pkg | sed 's/^+//' \
165-
)
166-
167-
The PURLs can be used as input to debsbom to download / merge components:
143+
debsbom compare sbom.old.cdx.json sbom.cdx.json extras.cdx.json
168144
169-
.. code-block:: bash
170-
171-
echo "$PURLS" | debsbom download --sources --binaries
172-
173-
Once downloaded, it is possible to merge the source packages:
174-
175-
.. code-block:: bash
176-
177-
echo "$PURLS" | debsbom source-merge --apply-patches
178-
179-
And the same list of packages can be repacked:
145+
You can also pass SBOMs via stdin, but you also have to pass the SBOM type in this case:
180146
181147
.. code-block:: bash
182148
183-
echo "$PURLS" | debsbom repack \
184-
--apply-patches
185-
sbom.cdx.json \
186-
sbom.cdx.repacked.json
149+
cat sbom.old.spdx.json sbom.spdx.json | debsbom compare -t spdx - - -o -
187150
188151
Export as Graph
189152
~~~~~~~~~~~~~~~
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
:orphan:
2+
3+
debsbom compare
4+
===============
5+
6+
.. argparse::
7+
:module: debsbom.cli
8+
:func: setup_parser
9+
:prog: debsbom
10+
:path: compare
11+
:manpage:
12+
13+
.. automodule:: debsbom.commands.compare.CompareCmd
14+
:noindex:
15+
16+
.. include:: ../commands/compare-description.inc
17+
18+
SEE ALSO
19+
--------
20+
21+
:manpage:`debsbom-generate(1)`
22+
23+
.. include:: _debsbom-man-footer.inc

src/debsbom/cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .commands.source_merge import SourceMergeCmd
1919
from .commands.repack import RepackCmd
2020
from .commands.export import ExportCmd
21+
from .commands.compare import CompareCmd
2122

2223
# Attempt to import optional download dependencies to check their availability.
2324
# The success or failure of these imports determines if download features are enabled.
@@ -64,6 +65,9 @@ def setup_parser():
6465
)
6566
RepackCmd.setup_parser(subparser.add_parser("repack", help="repack sources and sbom"))
6667
ExportCmd.setup_parser(subparser.add_parser("export", help="export SBOM as graph"))
68+
CompareCmd.setup_parser(
69+
subparser.add_parser("compare", help="compare SBOMs and list new components")
70+
)
6771

6872
return parser
6973

@@ -97,6 +101,8 @@ def main():
97101
ExportCmd.run(args)
98102
elif args.cmd == "merge":
99103
MergeCmd.run(args)
104+
elif args.cmd == "compare":
105+
CompareCmd.run(args)
100106
except DistroArchUnknownError as e:
101107
logger.error(f"debsbom: error: {e}. Set --distro-arch to dpkg architecture (e.g. amd64)")
102108
sys.exit(-2)

src/debsbom/commands/compare.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# Copyright (C) 2025 Siemens
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
import json
6+
from pathlib import Path
7+
import sys
8+
9+
10+
from ..bomwriter import BomWriter
11+
from .input import GenerateInput, warn_if_tty
12+
from ..sbom import SBOMType
13+
14+
15+
class CompareCmd(GenerateInput):
16+
"""
17+
Compare two SBOMs and generate a new SBOM containing only the additional components found in the target
18+
"""
19+
20+
@classmethod
21+
def run(cls, args):
22+
inputs = [
23+
("base", args.base_sbom),
24+
("target", args.target_sbom),
25+
]
26+
27+
base_json_obj = target_json_obj = None
28+
base_path = target_path = None
29+
base_sbom_fmt = target_sbom_fmt = None
30+
base_sbom_obj = target_sbom_obj = None
31+
32+
json_sboms = []
33+
stdin_consumed = False
34+
35+
for kind, sbom_arg in inputs:
36+
# read from stdin
37+
if sbom_arg == "-":
38+
warn_if_tty()
39+
if args.sbom_type is None:
40+
raise ValueError("option --sbom-type is required when reading SBOMs from stdin")
41+
42+
if not stdin_consumed:
43+
decoder = json.JSONDecoder()
44+
s = sys.stdin.read()
45+
json_obj, _ = decoder.raw_decode(s)
46+
len_s = len(s)
47+
read_total = 0
48+
while read_total < len_s:
49+
json_obj, read = decoder.raw_decode(s[read_total:])
50+
read_total += read
51+
json_sboms.append(json_obj)
52+
53+
stdin_consumed = True
54+
55+
# Pop the next object for this argument
56+
try:
57+
json_obj = json_sboms.pop(0)
58+
except IndexError:
59+
raise ValueError("Not enough SBOMs provided on stdin")
60+
61+
fmt = args.sbom_type
62+
63+
if kind == "base":
64+
base_json_obj = json_obj
65+
base_sbom_fmt = fmt
66+
else:
67+
target_json_obj = json_obj
68+
target_sbom_fmt = fmt
69+
70+
else:
71+
sbom_path = Path(sbom_arg)
72+
if ".spdx" in sbom_path.suffixes:
73+
fmt = "spdx"
74+
elif ".cdx" in sbom_path.suffixes:
75+
fmt = "cdx"
76+
else:
77+
raise ValueError(f"cannot detect SBOM format for {sbom_arg}")
78+
79+
if kind == "base":
80+
base_path = sbom_path
81+
base_sbom_fmt = fmt
82+
else:
83+
target_path = sbom_path
84+
target_sbom_fmt = fmt
85+
86+
if base_sbom_fmt != target_sbom_fmt:
87+
raise ValueError("can not compare mixed SPDX and CycloneDX documents")
88+
89+
SBOMType.from_str(target_sbom_fmt).validate_dependency_availability()
90+
91+
from ..compare.compare import SbomCompare
92+
93+
bom = SbomCompare.run_compare(
94+
fmt=target_sbom_fmt,
95+
args=args,
96+
base_json_obj=base_json_obj,
97+
target_json_obj=target_json_obj,
98+
base_path=base_path,
99+
target_path=target_path,
100+
)
101+
102+
sbom_type = SBOMType.SPDX if target_sbom_fmt == "spdx" else SBOMType.CycloneDX
103+
104+
if args.out == "-":
105+
BomWriter.write_to_stream(bom, sbom_type, sys.stdout, args.validate)
106+
else:
107+
out = args.out
108+
suffix = ".spdx.json" if target_sbom_fmt == "spdx" else ".cdx.json"
109+
if not out.endswith(suffix):
110+
out += suffix
111+
BomWriter.write_to_file(bom, sbom_type, Path(out), args.validate)
112+
113+
@classmethod
114+
def setup_parser(cls, parser):
115+
cls.parser_add_generate_input_args(parser, default_out="extras")
116+
parser.add_argument(
117+
"-t",
118+
"--sbom-type",
119+
choices=["cdx", "spdx"],
120+
help="expected SBOM type when reading SBOMs from stdin, required when reading from stdin",
121+
)
122+
parser.add_argument(
123+
"base_sbom",
124+
metavar="BASE_SBOM",
125+
help="Path to the base (reference) SBOM file",
126+
)
127+
parser.add_argument(
128+
"target_sbom",
129+
metavar="TARGET_SBOM",
130+
help="Path to the target (new) SBOM file",
131+
)

0 commit comments

Comments
 (0)