Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add cycloneDX as a Conan tool and implement subgraph for conanfile #17559

Merged
merged 9 commits into from
Jan 23, 2025
Merged
Empty file added conan/tools/sbom/__init__.py
Empty file.
73 changes: 73 additions & 0 deletions conan/tools/sbom/cycloneDX.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@

ErniGH marked this conversation as resolved.
Show resolved Hide resolved
def cyclonedx_1_4(graph, **kwargs):
ErniGH marked this conversation as resolved.
Show resolved Hide resolved
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
17 changes: 17 additions & 0 deletions conans/client/graph/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions conans/model/conan_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions test/functional/sbom/test_cycloneDX.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import textwrap
ErniGH marked this conversation as resolved.
Show resolved Hide resolved

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")
ErniGH marked this conversation as resolved.
Show resolved Hide resolved

def post_package(conanfile):
ErniGH marked this conversation as resolved.
Show resolved Hide resolved
_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")
ErniGH marked this conversation as resolved.
Show resolved Hide resolved
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/[email protected]?rref=6a99f55e933fb6feeb96df134c33af44" in content
43 changes: 43 additions & 0 deletions test/integration/graph/test_subgraph_reports.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading