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

Sbom command and plugin support #17203

Draft
wants to merge 14 commits into
base: develop2
Choose a base branch
from
2 changes: 1 addition & 1 deletion conan/api/subapi/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,4 @@ def install_consumer(self, deps_graph, generators=None, source_folder=None, outp
final_generators.append(gen)
conanfile.generators = final_generators
app = ConanApp(self.conan_api)
write_generators(conanfile, app, envs_generation=envs_generation)
write_generators(conanfile, app, envs_generation=envs_generation) # TODO add deps_graph without cli node
23 changes: 22 additions & 1 deletion conan/internal/api/install/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import traceback
import importlib

from conan.api.output import ConanOutput
from conan.internal.cache.home_paths import HomePaths
from conans.client.subsystems import deduce_subsystem, subsystem_path
from conan.internal.errors import conanfile_exception_formatter
Expand Down Expand Up @@ -71,7 +72,7 @@ def load_cache_generators(path):
return result


def write_generators(conanfile, app, envs_generation=None):
def write_generators(conanfile, app, envs_generation=None, deps_graph=None):
new_gen_folder = conanfile.generators_folder
_receive_conf(conanfile)

Expand Down Expand Up @@ -135,6 +136,8 @@ def write_generators(conanfile, app, envs_generation=None):
env.generate()

_generate_aggregated_env(conanfile)
if deps_graph:
_generate_graph_manifests(deps_graph, app)

hook_manager.execute("post_generate", conanfile=conanfile)

Expand All @@ -152,6 +155,24 @@ def _receive_conf(conanfile):
conanfile.conf.compose_conf(build_require.conf_info)


def _generate_graph_manifests(sub_graph, app):
from conans.client.loader import load_python_file
sbom_plugin_path = HomePaths(app.cache_folder).sbom_manifest_plugin_path
if os.path.exists(sbom_plugin_path):
mod, _ = load_python_file(sbom_plugin_path)

if not hasattr(mod, "generate_sbom"):
raise ConanException(
f"SBOM manifest plugin does not have a 'generate_sbom' method")
if not callable(mod.generate_sbom):
raise ConanException(
f"SBOM manifest plugin 'generate_sbom' is not a function")

ConanOutput().warning(f"generating sbom", warn_tag="experimental")
# TODO think if this is conanfile or conanfile._conan_node
return mod.generate_sbom(sub_graph)


def _generate_aggregated_env(conanfile):

def deactivates(filenames):
Expand Down
4 changes: 4 additions & 0 deletions conan/internal/cache/home_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ def auth_source_plugin_path(self):
def sign_plugin_path(self):
return os.path.join(self._home, _EXTENSIONS_FOLDER, _PLUGINS, "sign", "sign.py")

@property
def sbom_manifest_plugin_path(self):
return os.path.join(self._home, _EXTENSIONS_FOLDER, _PLUGINS, "sbom.py")

@property
def remotes_path(self):
return os.path.join(self._home, "remotes.json")
Expand Down
17 changes: 17 additions & 0 deletions conans/client/graph/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ def __init__(self, ref, conanfile, context, recipe=None, path=None, test=False):
self.is_conf = False
self.replaced_requires = {} # To track the replaced requires for self.dependencies[old-ref]

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
7 changes: 7 additions & 0 deletions conans/client/graph/sbom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from conans.client.graph.spdx import spdx_json_generator
from conan.internal.cache.home_paths import HomePaths

def migrate_sbom_file(cache_folder):
from conans.client.migrations import update_file
sbom_path = HomePaths(cache_folder).sbom_manifest_plugin_path
update_file(sbom_path, spdx_json_generator)
56 changes: 56 additions & 0 deletions conans/client/graph/spdx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
spdx_json_generator = """
import time
import json
from datetime import datetime, timezone
from conan import conan_version
from conan.errors import ConanException

import pathlib

def generate_sbom(graph, **kwargs):
name = graph.root.name
version = graph.root.ref.version
date = datetime.fromtimestamp(time.time(), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
Copy link
Member

Choose a reason for hiding this comment

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

Which date should be here? Maybe the revision date is more correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the creation date identify when the SPDX document was originally created

https://spdx.github.io/spdx-spec/v2-draft/document-creation-information/#69-created-field

packages = []
for dependency in graph.nodes:
packages.append(
{
"name": dependency.ref.name,
"SPDXID": f"SPDXRef-{dependency.ref}",
"version": str(dependency.ref.version),
"license": dependency.conanfile.license or "NOASSERTION",
})
files = []
# https://spdx.github.io/spdx-spec/v2.2.2/package-information/
data = {
"SPDXVersion": "SPDX-2.2",
"DataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"DocumentName": f"{name}-{version}",
"DocumentNamespace": f"http://spdx.org/spdxdocs/{name}-{version}-{date}", # the date or hash to make it unique
"Creator": f"Tool: Conan-{conan_version}",
"Created": date, #YYYY-MM-DDThh:mm:ssZ
"Packages": [{
"PackageName": p["name"],
"SPDXID": p["SPDXID"],
"PackageVersion": p["version"],
"PackageDownloadLocation": "NOASSERTION",
"FilesAnalyzed": False,
"PackageLicenseConcluded": p["license"],
"PackageLicenseDeclared": p["license"],
} for p in packages],
# "Files": [{
# "FileName": f["path"], # Path to file
# "SPDXID": f["SPDXID"],
# "FileChecksum": f'{f["checksum_algorithm"]}: {f["checksum_algorithm_value"]}',
# "LicenseConcluded": f["licence"],
# "LicenseInfoInFile": f["licence"],
# "FileCopyrightText": "NOASSERTION"
# } for f in files],
}
try:
with open(f"../{name}-{version}.spdx.json", 'w') as f:
json.dump(data, f, indent=4)
except Exception as e:
ConanException("error generating spdx file")
"""
2 changes: 1 addition & 1 deletion conans/client/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def _copy_sources(conanfile, source_folder, build_folder):
raise ConanException("%s\nError copying sources to build folder" % msg)

def _build(self, conanfile, pref):
write_generators(conanfile, self._app)
write_generators(conanfile, self._app, deps_graph=conanfile.subgraph)

try:
run_build_method(conanfile, self._hook_manager)
Expand Down
3 changes: 3 additions & 0 deletions conans/client/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ def _apply_migrations(self, old_version):
# Update profile plugin
from conan.internal.api.profile.profile_loader import migrate_profile_plugin
migrate_profile_plugin(self.cache_folder)
# Update sbom manifest plugins
from conans.client.graph.sbom import migrate_sbom_file
migrate_sbom_file(self.cache_folder)

if old_version and old_version < "2.0.14-":
_migrate_pkg_db_lru(self.cache_folder, old_version)
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 @@ -186,6 +186,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
44 changes: 44 additions & 0 deletions test/integration/graph/test_subgraph_reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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"])

Empty file.
24 changes: 24 additions & 0 deletions test/integration/sbom/test_sbom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from conan.test.assets.genconanfile import GenConanfile
from conan.test.utils.tools import TestClient
import os


def test_sbom_generation_create():
tc = TestClient()
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")
foo_layout = tc.created_layout()

assert os.path.exists(os.path.join(foo_layout.build(), "foo-1.0.spdx.json"))

def test_sbom_generation_install():
tc = TestClient()
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")

tc.run("install --requires=foo/1.0")
assert os.path.exists(os.path.join(tc.current_folder, "foo-1.0.spdx.json")) #TODO FIX this test