-
Notifications
You must be signed in to change notification settings - Fork 994
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add cycloneDX as a Conan tool and implement subgraph for conanfile (#…
…17559) * subgraph with test and cyclone as tool and test * use metadata folder and split sbom tests * rename files, remove try-excepts, use transitive_libraries fixture * fix purl and sbom file name * experimental docstring * fix tests * fix import
- Loading branch information
Showing
6 changed files
with
283 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
|
||
def cyclonedx_1_4(graph, **kwargs): | ||
""" | ||
(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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
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"]) |