Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 24 additions & 16 deletions conan/tools/sbom/cyclonedx.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from conan import conan_version


def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwargs):
def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, qualifiers=None, **kwargs):
"""
(Experimental) Generate cyclone 1.4 SBOM with JSON format

Expand All @@ -14,6 +14,7 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
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.
qualifiers (list[str], optional): Qualifiers show in PURL.

Returns:
The generated CycloneDX 1.4 document as a string.
Expand All @@ -28,6 +29,7 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
import time
from datetime import datetime, timezone
graph = conanfile.subgraph
qualifiers = qualifiers or []

has_special_root_node = not (getattr(graph.root.ref, "name", False)
and getattr(graph.root.ref, "version", False)
Expand All @@ -44,14 +46,14 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
dependencies = []
if has_special_root_node:
deps = {"ref": special_id,
"dependsOn": [_calculate_bomref(d.dst) for d in graph.root.edges
"dependsOn": [_calculate_bomref(d.dst, qualifiers) for d in graph.root.edges
if should_add_node(d.dst, add_build, add_tests)]}
dependencies.append(deps)
for c in nodes:
deps = {"ref": _calculate_bomref(c)}
deps = {"ref": _calculate_bomref(c, qualifiers)}
dep = [d for d in c.edges if should_add_node(d.dst, add_build, add_tests)]

depends_on = [_calculate_bomref(d.dst) for d in dep
depends_on = [_calculate_bomref(d.dst, qualifiers) for d in dep
if should_add_node(d.dst, add_build, add_tests)]
if depends_on:
deps["dependsOn"] = depends_on
Expand All @@ -60,23 +62,23 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
sbom_cyclonedx_1_4 = {
**({"components": [{
"author": node.conanfile.author or "Unknown",
"bom-ref": _calculate_bomref(node),
"bom-ref": _calculate_bomref(node, qualifiers),
"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}",
"purl": _calculate_bomref(node, qualifiers),
"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": {
"author": conanfile.author or "Unknown",
"bom-ref": special_id if has_special_root_node else _calculate_bomref(conanfile),
"bom-ref": special_id if has_special_root_node else _calculate_bomref(conanfile, qualifiers),
"name": name if name else name_default,
"type": "application" if conanfile.package_type == "application" else "library",
},
Expand All @@ -97,7 +99,7 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
return sbom_cyclonedx_1_4


def cyclonedx_1_6(conanfile, name=None, add_build=False, add_tests=False, **kwargs):
def cyclonedx_1_6(conanfile, name=None, add_build=False, add_tests=False, qualifiers=None, **kwargs):
"""
(Experimental) Generate cyclone 1.6 SBOM with JSON format

Expand All @@ -110,6 +112,7 @@ def cyclonedx_1_6(conanfile, name=None, add_build=False, add_tests=False, **kwar
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.
qualifiers (list[str], optional): Qualifiers show in PURL.

Returns:
The generated CycloneDX 1.6 document as a string.
Expand All @@ -124,6 +127,7 @@ def cyclonedx_1_6(conanfile, name=None, add_build=False, add_tests=False, **kwar
import time
from datetime import datetime, timezone
graph = conanfile.subgraph
qualifiers = qualifiers or []

has_special_root_node = not (getattr(graph.root.ref, "name", False)
and getattr(graph.root.ref, "version", False)
Expand All @@ -140,15 +144,15 @@ def cyclonedx_1_6(conanfile, name=None, add_build=False, add_tests=False, **kwar
dependencies = []
if has_special_root_node:
deps = {"ref": special_id,
"dependsOn": [_calculate_bomref(d.dst)
"dependsOn": [_calculate_bomref(d.dst, qualifiers)
for d in graph.root.edges
if should_add_node(d.dst, add_build, add_tests)]}
dependencies.append(deps)
for c in nodes:
deps = {"ref": _calculate_bomref(c)}
deps = {"ref": _calculate_bomref(c, qualifiers)}
dep = [d for d in c.edges if should_add_node(d.dst, add_build, add_tests)]

depends_on = [_calculate_bomref(d.dst) for d in dep
depends_on = [_calculate_bomref(d.dst, qualifiers) for d in dep
if should_add_node(d.dst, add_build, add_tests)]
if depends_on:
deps["dependsOn"] = depends_on
Expand All @@ -157,23 +161,23 @@ def cyclonedx_1_6(conanfile, name=None, add_build=False, add_tests=False, **kwar
sbom_cyclonedx_1_6 = {
**({"components": [{
**({"authors": [{"name": node.conanfile.author}]} if node.conanfile.author else {}),
"bom-ref": _calculate_bomref(node),
"bom-ref": _calculate_bomref(node, qualifiers),
"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}",
"purl": _calculate_bomref(node, qualifiers),
"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": [{"name": conanfile.author}]} if conanfile.author else {}),
"bom-ref": special_id if has_special_root_node else _calculate_bomref(conanfile),
"bom-ref": special_id if has_special_root_node else _calculate_bomref(conanfile, qualifiers),
"name": name if name else name_default,
"type": "application" if conanfile.package_type == "application" else "library"
},
Expand Down Expand Up @@ -211,10 +215,14 @@ def _calculate_licenses(component):
]


def _calculate_bomref(component):
def _calculate_bomref(component, qualifiers):
user = f"&user={component.ref.user}" if component.ref.user else ""
channel = f"&channel={component.ref.channel}" if component.ref.channel else ""
return f"pkg:conan/{component.name}@{component.ref.version}?rref={component.ref.revision}{user}{channel}"
_settings = dict(component.conanfile.settings_build.values_list) if hasattr(component, "conanfile") else {}
purl_qualifier = "".join(f"&{qualifier}={_settings.get(qualifier)}" for qualifier in qualifiers)
return (f"pkg:conan/{component.name}@{component.ref.version}"
f"?rref={component.ref.revision}&pref={component.pref.package_id}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have discussed that this should be rrev, but we have decided to do that in a different PR

f"{user}{channel}{purl_qualifier}")


def should_add_node(node, add_build, add_tests):
Expand Down
12 changes: 9 additions & 3 deletions test/functional/sbom/test_cyclonedx.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import textwrap
import json
from symbol import pass_stmt

import pytest

Expand Down Expand Up @@ -291,7 +292,8 @@ def test_sbom_generation_custom_name(self, cyclone_version, name, result):
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"))

def test_cyclonedx_check_content(self, cyclone_version):
@pytest.mark.parametrize("qualifiers", [None, ["os", "arch"]])
def test_cyclonedx_check_content(self, cyclone_version, qualifiers):
_sbom_hook_post_package = textwrap.dedent("""
import json
import os
Expand All @@ -300,7 +302,7 @@ def test_cyclonedx_check_content(self, cyclone_version):
from conan.tools.sbom import {cyclone_version}

def post_package(conanfile):
sbom_cyclonedx= {cyclone_version}(conanfile)
sbom_cyclonedx= {cyclone_version}(conanfile, qualifiers={qualifiers})
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 @@ -309,7 +311,7 @@ def post_package(conanfile):
""")
tc = TestClient()
hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
save(hook_path, _sbom_hook_post_package.format(cyclone_version=cyclone_version))
save(hook_path, _sbom_hook_post_package.format(cyclone_version=cyclone_version, qualifiers=qualifiers))
conanfile_bar = textwrap.dedent("""
from conan import ConanFile
class HelloConan(ConanFile):
Expand Down Expand Up @@ -353,6 +355,10 @@ def requirements(self):
assert not content_json["components"][0].get("author")
assert content_json["components"][0]["authors"][0]["name"] == 'conan-dev'
assert content_json["components"][0]["type"] == 'application'
if qualifiers:
assert all(q in content_json["components"][0]["purl"] for q in qualifiers)
else:
assert "pkg:conan/[email protected]?rref=ec5797d63ec2eab8056fb3087fcd5039&pref=" in content_json["components"][0]["purl"]

@pytest.mark.parametrize("user, channel, user_dep, channel_dep", [("user", None, "user_dep", None), ("user", "channel", "user_dep", "channel_dep")])
def test_sbom_user_path(self, hook_setup_post_package_tl, user, channel, user_dep, channel_dep):
Expand Down
Loading