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
4 changes: 4 additions & 0 deletions conan/internal/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
Empty file added conan/tools/sbom/__init__.py
Empty file.
76 changes: 76 additions & 0 deletions conan/tools/sbom/cyclonedx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@

def cyclonedx_1_4(graph, **kwargs):
ErniGH marked this conversation as resolved.
Show resolved Hide resolved
"""
(Experimental) Generate cyclone 1.4 sbom with json format
"""
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,
"purl": f"pkg:conan/{c.name}@{c.ref.version}",
"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
138 changes: 138 additions & 0 deletions test/functional/sbom/test_cyclonedx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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

# Using the sbom tool with "conan create"
sbom_hook_post_package = """
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 post_package(conanfile):
sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile.subgraph)
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_1_4, f, indent=4)
ConanOutput().success(f"CYCLONEDX CREATED - {conanfile.package_metadata_folder}")
"""

@pytest.fixture()
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)
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)
return tc



def test_sbom_generation_create(hook_setup_post_package_tl):
tc = hook_setup_post_package_tl
tc.run("new cmake_lib -d name=bar -d version=1.0 -d requires=engine/1.0 -f")
# bar -> engine/1.0 -> matrix/1.0
tc.run("create . --build=missing")
bar_layout = tc.created_layout()
assert os.path.exists(os.path.join(bar_layout.metadata(), "sbom.cdx.json"))

def test_sbom_generation_skipped_dependencies(hook_setup_post_package):
tc = hook_setup_post_package
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.metadata(), "sbom.cdx.json")
content = tc.load(cyclone_path)
# A skipped dependency also shows up in the sbom
assert "pkg:conan/[email protected]?rref=6a99f55e933fb6feeb96df134c33af44" 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

def post_generate(conanfile):
sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile.subgraph)
generators_folder = conanfile.generators_folder
file_name = "sbom.cdx.json"
os.mkdir(os.path.join(generators_folder, "sbom"))
with open(os.path.join(generators_folder, "sbom", file_name), 'w') as f:
json.dump(sbom_cyclonedx_1_4, f, indent=4)
ConanOutput().success(f"CYCLONEDX CREATED - {conanfile.generators_folder}")
"""

@pytest.fixture()
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)
return tc

def test_sbom_generation_install_requires(hook_setup_post_generate):
tc = hook_setup_post_generate
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, "sbom", "sbom.cdx.json"))

def test_sbom_generation_install_path(hook_setup_post_generate):
tc = hook_setup_post_generate
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, "sbom", "sbom.cdx.json"))

def test_sbom_generation_install_path_consumer(hook_setup_post_generate):
tc = hook_setup_post_generate
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, "sbom", "sbom.cdx.json"))

def test_sbom_generation_install_path_txt(hook_setup_post_generate):
tc = hook_setup_post_generate
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, "sbom", "sbom.cdx.json"))
48 changes: 48 additions & 0 deletions test/integration/graph/test_subgraph_reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import json
import os
import textwrap

from conan.test.assets.genconanfile import GenConanfile
from conan.test.utils.tools import TestClient
from conans.model.recipe_ref import RecipeReference
from conans.util.files import load


def _metadata(c, ref):
pref = c.get_latest_package_reference(RecipeReference.loads(ref))
return c.get_latest_pkg_layout(pref).metadata()
from conan.internal.model.lockfile import Lockfile
def test_subgraph_reports():
c = TestClient()
subgraph_hook = textwrap.dedent("""\
import os, json
from conan.tools.files import save
from conan.internal.model.lockfile import Lockfile
Copy link
Member

Choose a reason for hiding this comment

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

Let's not document this until we make it public API (no need to change anything here)

def post_package(conanfile):
subgraph = conanfile.subgraph
save(conanfile, os.path.join(conanfile.package_metadata_folder, f"conangraph.json"),
json.dumps(subgraph.serialize(), indent=2))
save(conanfile, os.path.join(conanfile.package_metadata_folder, f"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(_metadata(c, "app/0.1"), "conangraph.json")))
pkg_graph = json.loads(load(os.path.join(_metadata(c, "pkg/0.1"), "conangraph.json")))
dep_graph = json.loads(load(os.path.join(_metadata(c, "dep/0.1"), "conangraph.json")))

app_lock = json.loads(load(os.path.join(_metadata(c, "app/0.1"), "conan.lock")))
pkg_lock = json.loads(load(os.path.join(_metadata(c, "pkg/0.1"), "conan.lock")))
dep_lock = json.loads(load(os.path.join(_metadata(c, "dep/0.1"), "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