diff --git a/conan/tools/sbom/__init__.py b/conan/tools/sbom/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/conan/tools/sbom/cycloneDX.py b/conan/tools/sbom/cycloneDX.py new file mode 100644 index 00000000000..478ddad3aee --- /dev/null +++ b/conan/tools/sbom/cycloneDX.py @@ -0,0 +1,73 @@ + +def cyclonedx_1_4(graph, **kwargs): + import uuid + import time + from datetime import datetime, timezone + + 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] + if has_special_root_node: + components = components[1:] + + dependencies = [] + if has_special_root_node: + deps = {"ref": special_id, + "dependsOn": [f"pkg:conan/{d.dst.name}@{d.dst.ref.version}?rref={d.dst.ref.revision}" + for d in graph.root.dependencies]} + 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] + if depends_on: + deps["dependsOn"] = depends_on + dependencies.append(deps) + + def _calculate_licenses(component): + if isinstance(component.conanfile.license, str): # Just one license + return [{"license": { + "id": component.conanfile.license + }}] + return [{"license": { + "id": l + }} for l in c.conanfile.license] + + sbom_cyclonedx_1_4 = { + **({"components": [{ + "author": "Conan", + "bom-ref": special_id if has_special_root_node else f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}", + "description": c.conanfile.description, + **({"externalReferences": [{ + "type": "website", + "url": c.conanfile.homepage + }]} if c.conanfile.homepage else {}), + **({"licenses": _calculate_licenses(c)} if c.conanfile.license else {}), + "name": c.name, + "fpurl": f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}", + "type": "library", + "version": str(c.ref.version), + } for c in components]} if components else {}), + **({"dependencies": dependencies} if dependencies else {}), + "metadata": { + "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, + "type": "library" + }, + "timestamp": f"{datetime.fromtimestamp(time.time(), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}", + "tools": [{ + "externalReferences": [{ + "type": "website", + "url": "https://github.com/conan-io/conan" + }], + "name": "Conan-io" + }], + }, + "serialNumber": f"urn:uuid:{uuid.uuid4()}", + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + } + return sbom_cyclonedx_1_4 diff --git a/conans/client/graph/graph.py b/conans/client/graph/graph.py index 6e036d88c50..0bfb1263d97 100644 --- a/conans/client/graph/graph.py +++ b/conans/client/graph/graph.py @@ -70,6 +70,23 @@ def __init__(self, ref, conanfile, context, recipe=None, path=None, test=False): self.replaced_requires = {} # To track the replaced requires for self.dependencies[old-ref] self.skipped_build_requires = False + def subgraph(self): + nodes = [self] + opened = [self] + while opened: + new_opened = [] + for o in opened: + for n in o.neighbors(): + if n not in nodes: + nodes.append(n) + if n not in opened: + new_opened.append(n) + opened = new_opened + + graph = DepsGraph() + graph.nodes = nodes + return graph + def __lt__(self, other): """ @type other: Node diff --git a/conans/model/conan_file.py b/conans/model/conan_file.py index ac84185159a..75de12a7f23 100644 --- a/conans/model/conan_file.py +++ b/conans/model/conan_file.py @@ -189,6 +189,10 @@ def output(self): def context(self): return self._conan_node.context + @property + def subgraph(self): + return self._conan_node.subgraph() + @property def dependencies(self): # Caching it, this object is requested many times diff --git a/test/functional/sbom/test_cycloneDX.py b/test/functional/sbom/test_cycloneDX.py new file mode 100644 index 00000000000..0046aec70e2 --- /dev/null +++ b/test/functional/sbom/test_cycloneDX.py @@ -0,0 +1,116 @@ +import textwrap + +import pytest + +from conan.test.assets.genconanfile import GenConanfile +from conan.test.utils.tools import TestClient +from conans.util.files import save +import os + +sbom_hook = """ + +import json +import os +from conan.errors import ConanException +from conan.api.output import ConanOutput +from conan.tools.sbom.cycloneDX import cyclonedx_1_4 + +def _generate_cyclonedx_1_4_file(conanfile): + try: + sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile.subgraph) + metadata_folder = conanfile.package_metadata_folder + file_name = "cyclonedx_1_4.json" + with open(os.path.join(metadata_folder, file_name), 'w') as f: + json.dump(sbom_cyclonedx_1_4, f, indent=4) + ConanOutput().success(f"CYCLONEDX CREATED - {conanfile.package_metadata_folder}") + except Exception as e: + ConanException("error generating CYCLONEDX file") + +def post_package(conanfile): + _generate_cyclonedx_1_4_file(conanfile) + +def post_generate(conanfile): + _generate_cyclonedx_1_4_file(conanfile) +""" + +@pytest.fixture() +def hook_setup(): + tc = TestClient() + hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py") + save(hook_path, sbom_hook) + return tc + +def test_sbom_generation_create(hook_setup): + tc = hook_setup + tc.run("new cmake_lib -d name=dep -d version=1.0") + tc.run("export .") + tc.run("new cmake_lib -d name=foo -d version=1.0 -d requires=dep/1.0 -f") + tc.run("export .") + tc.run("new cmake_lib -d name=bar -d version=1.0 -d requires=foo/1.0 -f") + # bar -> foo -> dep + tc.run("create . --build=missing") + bar_layout = tc.created_layout() + assert os.path.exists(os.path.join(bar_layout.build(),"..", "d", "metadata", "cyclonedx_1_4.json")) + +def test_sbom_generation_install_requires(hook_setup): + tc = hook_setup + tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"), + "conanfile.py": GenConanfile("foo", "1.0").with_requires("dep/1.0")}) + tc.run("export dep") + tc.run("create . --build=missing") + + #cli -> foo -> dep + tc.run("install --requires=foo/1.0") + assert os.path.exists(os.path.join(tc.current_folder, "cyclonedx_1_4.json")) + +def test_sbom_generation_install_path(hook_setup): + tc = hook_setup + tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"), + "conanfile.py": GenConanfile("foo", "1.0").with_requires("dep/1.0")}) + tc.run("create dep") + + #foo -> dep + tc.run("install .") + assert os.path.exists(os.path.join(tc.current_folder, "cyclonedx_1_4.json")) + +def test_sbom_generation_install_path_consumer(hook_setup): + tc = hook_setup + tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"), + "conanfile.py": GenConanfile().with_requires("dep/1.0")}) + tc.run("create dep") + + #conanfile.py -> dep + tc.run("install .") + assert os.path.exists(os.path.join(tc.current_folder, "cyclonedx_1_4.json")) + +def test_sbom_generation_install_path_txt(hook_setup): + tc = hook_setup + tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"), + "conanfile.txt": textwrap.dedent( + """ + [requires] + dep/1.0 + """ + )}) + tc.run("create dep") + + #foo -> dep + tc.run("install .") + assert os.path.exists(os.path.join(tc.current_folder, "cyclonedx_1_4.json")) + +def test_sbom_generation_skipped_dependencies(hook_setup): + tc = hook_setup + tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"), + "app/conanfile.py": GenConanfile("app", "1.0") + .with_package_type("application") + .with_requires("dep/1.0"), + "conanfile.py": GenConanfile("foo", "1.0").with_tool_requires("app/1.0")}) + tc.run("create dep") + tc.run("create app") + tc.run("create .") + create_layout = tc.created_layout() + + cyclone_path = os.path.join(create_layout.build(), "..", "d", "metadata", "cyclonedx_1_4.json") + content = tc.load(cyclone_path) + # A skipped dependency also shows up in the sbom + assert "pkg:conan/dep@1.0?rref=6a99f55e933fb6feeb96df134c33af44" in content diff --git a/test/integration/graph/test_subgraph_reports.py b/test/integration/graph/test_subgraph_reports.py new file mode 100644 index 00000000000..e0ccd893eec --- /dev/null +++ b/test/integration/graph/test_subgraph_reports.py @@ -0,0 +1,43 @@ +import json +import os +import textwrap + +from conan.test.assets.genconanfile import GenConanfile +from conan.test.utils.tools import TestClient +from conans.util.files import load + + +def test_subgraph_reports(): + c = TestClient() + subgraph_hook = textwrap.dedent("""\ + import os, json + from conan.tools.files import save + from conans.model.graph_lock import Lockfile + def post_package(conanfile): + subgraph = conanfile.subgraph + save(conanfile, os.path.join(conanfile.package_folder, "..", "..", f"{conanfile.name}-conangraph.json"), + json.dumps(subgraph.serialize(), indent=2)) + save(conanfile, os.path.join(conanfile.package_folder, "..", "..", f"{conanfile.name}-conan.lock"), + Lockfile(subgraph).dumps()) + """) + + c.save_home({"extensions/hooks/subgraph_hook/hook_subgraph.py": subgraph_hook}) + c.save({"dep/conanfile.py": GenConanfile("dep", "0.1"), + "pkg/conanfile.py": GenConanfile("pkg", "0.1").with_requirement("dep/0.1"), + "app/conanfile.py": GenConanfile("app", "0.1").with_requirement("pkg/0.1")}) + c.run("export dep") + c.run("export pkg") + # app -> pkg -> dep + c.run("create app --build=missing --format=json") + + app_graph = json.loads(load(os.path.join(c.cache.builds_folder, "app-conangraph.json"))) + pkg_graph = json.loads(load(os.path.join(c.cache.builds_folder, "pkg-conangraph.json"))) + dep_graph = json.loads(load(os.path.join(c.cache.builds_folder, "dep-conangraph.json"))) + + app_lock = json.loads(load(os.path.join(c.cache.builds_folder, "app-conan.lock"))) + pkg_lock = json.loads(load(os.path.join(c.cache.builds_folder, "pkg-conan.lock"))) + dep_lock = json.loads(load(os.path.join(c.cache.builds_folder, "dep-conan.lock"))) + + assert len(app_graph["nodes"]) == len(app_lock["requires"]) + assert len(pkg_graph["nodes"]) == len(pkg_lock["requires"]) + assert len(dep_graph["nodes"]) == len(dep_lock["requires"])