Skip to content

Commit

Permalink
Fix cycloneDX SBOM (#17760)
Browse files Browse the repository at this point in the history
* fix

* drop subgraph param

* Update conan/tools/sbom/cyclonedx.py

Co-authored-by: James <[email protected]>

---------

Co-authored-by: Carlos Zoido <[email protected]>
Co-authored-by: James <[email protected]>
  • Loading branch information
3 people authored Feb 12, 2025
1 parent 5eadc26 commit 5d07f4d
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 12 deletions.
1 change: 1 addition & 0 deletions conan/tools/sbom/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from conan.tools.sbom.cyclonedx import cyclonedx_1_4
35 changes: 30 additions & 5 deletions conan/tools/sbom/cyclonedx.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@

def cyclonedx_1_4(graph, **kwargs):

def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwargs):
"""
(Experimental) Generate cyclone 1.4 sbom with json format
(Experimental) Generate cyclone 1.4 SBOM with JSON format
Creates a CycloneDX 1.4 Software Bill of Materials (SBOM) from a given dependency graph.
Parameters:
conanfile: The conanfile instance.
name (str, optional): Custom name for the metadata field.
add_build (bool, optional, default=False): Include build dependencies.
add_tests (bool, optional, default=False): Include test dependencies.
Returns:
The generated CycloneDX 1.4 document as a string.
Example usage:
```
cyclonedx(conanfile, name="custom_name", add_build=True, add_test=True, **kwargs)
```
"""
import uuid
import time
from datetime import datetime, timezone
graph = conanfile.subgraph

has_special_root_node = not (getattr(graph.root.ref, "name", False) and getattr(graph.root.ref, "version", False) and getattr(graph.root.ref, "revision", False))
special_id = str(uuid.uuid4())

components = [node for node in graph.nodes]
name_default = getattr(graph.root.ref, "name", False) or "conan-sbom"
name_default += f"/{graph.root.ref.version}" if bool(getattr(graph.root.ref, "version", False)) else ""
components = [node for node in graph.nodes if (node.context == "host" or add_build) and (not node.test or add_tests)]
if has_special_root_node:
components = components[1:]

Expand All @@ -22,7 +45,9 @@ def cyclonedx_1_4(graph, **kwargs):
dependencies.append(deps)
for c in components:
deps = {"ref": f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}"}
depends_on = [f"pkg:conan/{d.dst.name}@{d.dst.ref.version}?rref={d.dst.ref.revision}" for d in c.dependencies]
dep = [d for d in c.dependencies if (d.dst.context == "host" or add_build) and (not d.dst.test or add_tests)]

depends_on = [f"pkg:conan/{d.dst.name}@{d.dst.ref.version}?rref={d.dst.ref.revision}" for d in dep]
if depends_on:
deps["dependsOn"] = depends_on
dependencies.append(deps)
Expand Down Expand Up @@ -56,7 +81,7 @@ def _calculate_licenses(component):
"component": {
"author": "Conan",
"bom-ref": special_id if has_special_root_node else f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}",
"name": graph.root.conanfile.display_name,
"name": name if name else name_default,
"type": "library"
},
"timestamp": f"{datetime.fromtimestamp(time.time(), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}",
Expand Down
91 changes: 84 additions & 7 deletions test/functional/sbom/test_cyclonedx.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
import os
from conan.errors import ConanException
from conan.api.output import ConanOutput
from conan.tools.sbom.cyclonedx import cyclonedx_1_4
from conan.tools.sbom import cyclonedx_1_4
def post_package(conanfile):
sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile.subgraph)
sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile, add_build=%s, add_tests=%s)
metadata_folder = conanfile.package_metadata_folder
file_name = "sbom.cdx.json"
with open(os.path.join(metadata_folder, file_name), 'w') as f:
Expand All @@ -28,14 +28,28 @@ def post_package(conanfile):
def hook_setup_post_package():
tc = TestClient()
hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
save(hook_path, sbom_hook_post_package)
save(hook_path, sbom_hook_post_package % ("True", "True"))
return tc

@pytest.fixture()
def hook_setup_post_package_no_tool_requires():
tc = TestClient()
hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
save(hook_path, sbom_hook_post_package % ("False", "True"))
return tc

@pytest.fixture()
def hook_setup_post_package_no_test():
tc = TestClient()
hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
save(hook_path, sbom_hook_post_package % ("True", "False"))
return tc

@pytest.fixture()
def hook_setup_post_package_tl(transitive_libraries):
tc = transitive_libraries
hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
save(hook_path, sbom_hook_post_package)
save(hook_path, sbom_hook_post_package % ("True", "True"))
return tc


Expand Down Expand Up @@ -65,17 +79,66 @@ def test_sbom_generation_skipped_dependencies(hook_setup_post_package):
# A skipped dependency also shows up in the sbom
assert "pkg:conan/[email protected]?rref=6a99f55e933fb6feeb96df134c33af44" in content

def test_sbom_generation_no_tool_requires(hook_setup_post_package_no_tool_requires):
tc = hook_setup_post_package_no_tool_requires
tc.save({"app/conanfile.py": GenConanfile("app", "1.0")
.with_package_type("application"),
"conanfile.py": GenConanfile("foo", "1.0").with_tool_requires("app/1.0")})
tc.run("create app")
tc.run("create .")
create_layout = tc.created_layout()

cyclone_path = os.path.join(create_layout.metadata(), "sbom.cdx.json")
content = tc.load(cyclone_path)

assert "pkg:conan/app" not in content

def test_sbom_generation_transitive_test_requires(hook_setup_post_package_no_test):
tc = hook_setup_post_package_no_test
tc.save({"test_re/conanfile.py": GenConanfile("test_re", "1.0"),
"app/conanfile.py": GenConanfile("app", "1.0")
.with_package_type("application")
.with_test_requires("test_re/1.0"),
"conanfile.py": GenConanfile("foo", "1.0").with_tool_requires("app/1.0")})
tc.run("create test_re")

tc.run("create app")
create_layout = tc.created_layout()
cyclone_path = os.path.join(create_layout.metadata(), "sbom.cdx.json")
content = tc.load(cyclone_path)
assert "pkg:conan/[email protected]" not in content

tc.run("create .")
create_layout = tc.created_layout()
cyclone_path = os.path.join(create_layout.metadata(), "sbom.cdx.json")
content = tc.load(cyclone_path)
assert "pkg:conan/[email protected]" not in content

def test_sbom_generation_dependency_test_require(hook_setup_post_package_no_test):
tc = hook_setup_post_package_no_test
tc.save({"special/conanfile.py": GenConanfile("special", "1.0"),
"foo/conanfile.py": GenConanfile("foo", "1.0")
.with_test_requires("special/1.0"),
"conanfile.py": GenConanfile("bar", "1.0").with_tool_requires("foo/1.0").with_require("special/1.0")})
tc.run("create special")
tc.run("create foo")

tc.run("create .")
create_layout = tc.created_layout()
cyclone_path = os.path.join(create_layout.metadata(), "sbom.cdx.json")
content = tc.load(cyclone_path)
assert "pkg:conan/[email protected]" in content

# Using the sbom tool with "conan install"
sbom_hook_post_generate = """
import json
import os
from conan.errors import ConanException
from conan.api.output import ConanOutput
from conan.tools.sbom.cyclonedx import cyclonedx_1_4
from conan.tools.sbom import cyclonedx_1_4
def post_generate(conanfile):
sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile.subgraph)
sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile, name=%s)
generators_folder = conanfile.generators_folder
file_name = "sbom.cdx.json"
os.mkdir(os.path.join(generators_folder, "sbom"))
Expand All @@ -88,7 +151,7 @@ def post_generate(conanfile):
def hook_setup_post_generate():
tc = TestClient()
hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
save(hook_path, sbom_hook_post_generate)
save(hook_path, sbom_hook_post_generate % "None")
return tc

def test_sbom_generation_install_requires(hook_setup_post_generate):
Expand Down Expand Up @@ -136,3 +199,17 @@ def test_sbom_generation_install_path_txt(hook_setup_post_generate):
#foo -> dep
tc.run("install .")
assert os.path.exists(os.path.join(tc.current_folder, "sbom", "sbom.cdx.json"))

@pytest.mark.parametrize("name, result", [
("None", "conan-sbom"),
('"custom-name"', "custom-name")
])
def test_sbom_generation_custom_name(name, result):
tc = TestClient()
hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
save(hook_path, sbom_hook_post_generate % name)

tc.save({"conanfile.py": GenConanfile()})
tc.run("install .")
assert os.path.exists(os.path.join(tc.current_folder, "sbom", "sbom.cdx.json"))
assert f'"name": "{result}"' in tc.load(os.path.join(tc.current_folder, "sbom", "sbom.cdx.json"))

0 comments on commit 5d07f4d

Please sign in to comment.