Repository: conan-io/conan
Pull request #18108 (2025-04-10 14:14:20)

add `CycloneDX 1.6`support
Changelog: Feature: Add `CycloneDx 1.6` support
Docs: TODO

A small test has also been added to differentiate the content between versions.

Here is a list of each field in the SBOM that has been modified or not compared to version 1.4, using the standard as a template.
CycloneDX 1.4 https://cyclonedx.org/docs/1.4/json/
CycloneDX 1.6 https://cyclonedx.org/docs/1.6/json/

- Components:
    - Author: is deprecated, so this field was removed
    - Authors: The field is added, but is not required. It is added only if the recipe has the author variable
    - Bom-ref: keep the same 
    - Description: keep the same 
    - External references: keep the same 
    - Licenses: keep the same, now it can’t be an empty list, but we didn’t have that problem
    - Name: keep the same
    - Purl: keep the same
    - Type: now can be a library or an application. This upgrade can be done in 1.4
    - Version: keep the same
- Dependencies: keep the same
- Metadata:
    - Component:
        - Author: is deprecated, so this field was removed
        - Authors: The field is added, but is not required. It is added only if the recipe has the author variable
        - Bom-ref: keep the same
        - Name: keep the same
        - Type: library or application. This upgrade can be done in 1.4
    - Timestamp: keep the same
    - Tools: completely updated, the previous format is deprecated
        - The type and name fields are required
        - The version field is not required, but it is easy to implement, and it could be helpful in the future
- Serial number: keep the same
- bomFormat: keeps the same as "CycloneDX"
- specVersion: update to “1.6”
- Version: now is not required, but we will keep




czoido: I think it might be good to refactor the common parts to avoid introducing errors when adding new spec versions later? Also probably easier for review.

Patch:
diff --git a/conan/tools/sbom/__init__.py b/conan/tools/sbom/__init__.py
index 1f1641e12..8d8cd91e2 100644
--- a/conan/tools/sbom/__init__.py
+++ b/conan/tools/sbom/__init__.py
@@ -1,1 +1,1 @@
-from conan.tools.sbom.cyclonedx import cyclonedx_1_4
+from conan.tools.sbom.cyclonedx import cyclonedx_1_4, cyclonedx_1_6
diff --git a/conan/tools/sbom/cyclonedx.py b/conan/tools/sbom/cyclonedx.py
index bca937a33..e24aca912 100644
--- a/conan/tools/sbom/cyclonedx.py
+++ b/conan/tools/sbom/cyclonedx.py
@@ -1,4 +1,4 @@
-
+from conan import conan_version
 
 def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwargs):
     """
@@ -19,7 +19,7 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
 
     Example usage:
     ```
-    cyclonedx(conanfile, name="custom_name", add_build=True, add_test=True, **kwargs)
+    cyclonedx_1_4(conanfile, name="custom_name", add_build=True, add_test=True, **kwargs)
     ```
 
     """
@@ -73,7 +73,7 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
             **({"licenses": _calculate_licenses(node)} if node.conanfile.license else {}),
             "name": node.name,
             "purl": f"pkg:conan/{node.name}@{node.ref.version}",
-            "type": "library",
+            "type": "application" if node.conanfile.package_type == "application" else "library",
             "version": str(node.ref.version),
         } for node in nodes]} if nodes else {}),
         **({"dependencies": dependencies} if dependencies else {}),
@@ -82,7 +82,7 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
                 "author": conanfile.author or "Unknown",
                 "bom-ref": special_id if has_special_root_node else f"pkg:conan/{conanfile.name}@{conanfile.ref.version}?rref={conanfile.ref.revision}",
                 "name": name if name else name_default,
-                "type": "library"
+                "type": "application" if conanfile.package_type == "application" else "library",
             },
             "timestamp": f"{datetime.fromtimestamp(time.time(), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}",
             "tools": [{
@@ -99,3 +99,103 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
         "version": 1,
     }
     return sbom_cyclonedx_1_4
+
+def cyclonedx_1_6(conanfile, name=None, add_build=False, add_tests=False, **kwargs):
+    """
+    (Experimental) Generate cyclone 1.6 SBOM with JSON format
+
+    Creates a CycloneDX 1.6 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.6 document as a string.
+
+    Example usage:
+    ```
+    cyclonedx_1_6(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())
+
+    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 ""
+    nodes = [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:
+        nodes = nodes[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 nodes:
+        deps = {"ref": f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}"}
+        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)
+
+    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 component.conanfile.license]
+
+    sbom_cyclonedx_1_6 = {
+        **({"components": [{
+            **({"authors": [node.conanfile.author]} if node.conanfile.author else {}),
+            "bom-ref": special_id if has_special_root_node else f"pkg:conan/{node.name}@{node.ref.version}?rref={node.ref.revision}",
+            "description": node.conanfile.description,
+            **({"externalReferences": [{
+                "type": "website",
+                "url": node.conanfile.homepage
+            }]} if node.conanfile.homepage else {}),
+            **({"licenses": _calculate_licenses(node)} if node.conanfile.license else {}),
+            "name": node.name,
+            "purl": f"pkg:conan/{node.name}@{node.ref.version}",
+            "type": "application" if node.conanfile.package_type == "application" else "library",
+            "version": str(node.ref.version),
+        } for node in nodes]} if nodes else {}),
+        **({"dependencies": dependencies} if dependencies else {}),
+        "metadata": {
+            "component": {
+                **({"authors": [conanfile.author]} if conanfile.author else {}),
+                "bom-ref": special_id if has_special_root_node else f"pkg:conan/{conanfile.name}@{conanfile.ref.version}?rref={conanfile.ref.revision}",
+                "name": name if name else name_default,
+                "type": "application" if conanfile.package_type == "application" else "library"
+            },
+            "timestamp": f"{datetime.fromtimestamp(time.time(), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}",
+            "tools": {
+                "components":[{
+                    "type": "application",
+                    "name": "Conan-io",
+                    "version": str(conan_version),
+                }]
+            },
+        },
+        "serialNumber": f"urn:uuid:{uuid.uuid4()}",
+        "bomFormat": "CycloneDX",
+        "specVersion": "1.6",
+        "version": 1,
+    }
+    return sbom_cyclonedx_1_6


Test patch:
diff --git a/test/functional/sbom/test_cyclonedx.py b/test/functional/sbom/test_cyclonedx.py
index 1352e80be..a7fbf2fa7 100644
--- a/test/functional/sbom/test_cyclonedx.py
+++ b/test/functional/sbom/test_cyclonedx.py
@@ -270,3 +270,67 @@ def test_sbom_generation_custom_name(name, result):
     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"))
+
+@pytest.mark.parametrize("cyclone_version", ["cyclonedx_1_4", "cyclonedx_1_6"])
+def test_cyclonedx_check_content(cyclone_version):
+    _sbom_hook_post_package = textwrap.dedent("""
+    import json
+    import os
+    from conan.errors import ConanException
+    from conan.api.output import ConanOutput
+    from conan.tools.sbom import %s
+
+    def post_package(conanfile):
+        sbom_cyclonedx= %s(conanfile)
+        metadata_folder = conanfile.package_metadata_folder
+        file_name = "sbom.cdx.json"
+        with open(os.path.join(metadata_folder, file_name), 'w') as f:
+            json.dump(sbom_cyclonedx, f, indent=4)
+        ConanOutput().success(f"CYCLONEDX CREATED - {conanfile.package_metadata_folder}")
+    """)
+    tc = TestClient()
+    hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
+    save(hook_path, _sbom_hook_post_package % (cyclone_version, cyclone_version))
+    conanfile_bar = textwrap.dedent("""
+            from conan import ConanFile
+            class HelloConan(ConanFile):
+                name = 'bar'
+                version = '1.0'
+                author = 'conan-dev'
+                package_type = 'application'
+        """)
+
+    conanfile_foo = textwrap.dedent("""
+        from conan import ConanFile
+        class HelloConan(ConanFile):
+            name = 'foo'
+            version = '1.0'
+            author = 'conan-dev'
+            package_type = 'application'
+
+            def requirements(self):
+                self.requires("bar/1.0")
+    """)
+    tc.save({"conanfile.py": conanfile_bar})
+    tc.run("create .")
+    tc.save({"conanfile.py": conanfile_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)
+    content_json = json.loads(content)
+    if cyclone_version == 'cyclonedx_1_4':
+        assert content_json["metadata"]["component"]["author"]
+        assert content_json["metadata"]["component"]["type"] == 'application'
+        assert content_json["metadata"]["tools"][0]
+        assert content_json["components"][0]["author"]
+        assert content_json["components"][0]["type"] == 'application'
+    elif cyclone_version == 'cyclonedx_1_6':
+        assert not content_json["metadata"]["component"].get("author")
+        assert content_json["metadata"]["component"]["authors"]
+        assert content_json["metadata"]["component"]["type"] == 'application'
+        assert content_json["metadata"]["tools"]["components"][0]
+        assert not content_json["components"][0].get("author")
+        assert content_json["components"][0]["authors"]
+        assert content_json["components"][0]["type"] == 'application'