Support SBOM for depsolving in osbuild-depsolve-dnf

Extend osbuild-depsolve-dnf, to return JSON with SPDX SBOM that
corresponds to the depsolved package set, if it has been requested.
For now, only DNF4 is supported.

Cover the new functionality with unit test.

Signed-off-by: Tomáš Hozza <thozza@redhat.com>
This commit is contained in:
Tomáš Hozza 2024-06-26 17:02:26 +02:00 committed by Simon de Vlieger
parent 65ef88687e
commit 1d8bd0f8a6
4 changed files with 105 additions and 15 deletions

View file

@ -2,12 +2,14 @@ import os
import os.path
import tempfile
from datetime import datetime
from typing import List
from typing import Dict, List
import dnf
import hawkey
from osbuild.solver import DepsolveError, MarkingError, RepoError, SolverBase, modify_rootdir_path, read_keys
from osbuild.util.sbom.dnf import dnf_pkgset_to_sbom_pkgset
from osbuild.util.sbom.spdx import bom_pkgset_to_spdx2_doc
class DNF(SolverBase):
@ -158,6 +160,17 @@ class DNF(SolverBase):
def _timestamp_to_rfc3339(timestamp):
return datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%dT%H:%M:%SZ')
@staticmethod
def _sbom_for_pkgset(pkgset: List[dnf.package.Package]) -> Dict:
"""
Create an SBOM document for the given package set.
For now, only SPDX v2 is supported.
"""
pkgset = dnf_pkgset_to_sbom_pkgset(pkgset)
spdx_doc = bom_pkgset_to_spdx2_doc(pkgset)
return spdx_doc.to_dict()
def dump(self):
packages = []
for package in self.base.sack.query().available():
@ -230,7 +243,8 @@ class DNF(SolverBase):
return packages
def depsolve(self, arguments):
# # Return an empty list when 'transactions' key is missing or when it is None
want_sbom = "sbom" in arguments
# Return an empty list when 'transactions' key is missing or when it is None
transactions = arguments.get("transactions") or []
# collect repo IDs from the request so we know whether to translate gpg key paths
request_repo_ids = set(repo["id"] for repo in arguments.get("repos", []))
@ -310,4 +324,8 @@ class DNF(SolverBase):
"packages": packages,
"repos": repositories,
}
if want_sbom:
response["sbom"] = self._sbom_for_pkgset(last_transaction)
return response

View file

@ -11,7 +11,14 @@ from libdnf5.common import QueryCmp_CONTAINS as CONTAINS
from libdnf5.common import QueryCmp_EQ as EQ
from libdnf5.common import QueryCmp_GLOB as GLOB
from osbuild.solver import DepsolveError, MarkingError, RepoError, SolverBase, modify_rootdir_path, read_keys
from osbuild.solver import (
DepsolveError,
MarkingError,
RepoError,
SolverBase,
modify_rootdir_path,
read_keys,
)
def remote_location(package, schemes=("http", "ftp", "file", "https")):

View file

@ -145,11 +145,13 @@ def validate_request(request):
"kind": "InvalidRequest",
"reason": "no 'module_platform_id' specified"
}
if not request.get("releasever"):
return {
"kind": "InvalidRequest",
"reason": "no 'releasever' specified"
}
arguments = request.get("arguments")
if not arguments:
return {
@ -157,6 +159,44 @@ def validate_request(request):
"reason": "empty 'arguments'"
}
sbom = request["arguments"].get("sbom")
if sbom is not None:
# NB: check the DNF5 flag here, instead of in the dnf5 module,
# to consistently return this error message, even if there are other
# potential errors in the request, such as broken repository.
if config.get("use_dnf5", False):
return {
"kind": "InvalidRequest",
"reason": "SBOM support for DNF5 is not implemented"
}
if command != "depsolve":
return {
"kind": "InvalidRequest",
"reason": "SBOM is only supported with 'depsolve' command"
}
if not isinstance(sbom, dict):
return {
"kind": "InvalidRequest",
"reason": "invalid 'sbom' value"
}
sbom_type = sbom.get("type")
if sbom_type is None:
return {
"kind": "InvalidRequest",
"reason": "missing 'type' in 'sbom'"
}
if not isinstance(sbom_type, str):
return {
"kind": "InvalidRequest",
"reason": "invalid 'type' in 'sbom'"
}
if sbom_type != "spdx":
return {
"kind": "InvalidRequest",
"reason": "Unsupported SBOM type"
}
if not arguments.get("repos") and not arguments.get("root_dir"):
return {
"kind": "InvalidRequest",

View file

@ -13,6 +13,7 @@ from itertools import combinations
from tempfile import TemporaryDirectory
from typing import Tuple
import jsonschema
import pytest
REPO_PATHS = [
@ -38,7 +39,7 @@ def assert_dnf():
raise RuntimeError("Cannot import libdnf")
def depsolve(transactions, repos, root_dir, cache_dir, dnf_config, opt_metadata) -> Tuple[dict, int]:
def depsolve(transactions, repos, root_dir, cache_dir, dnf_config, opt_metadata, with_sbom=False) -> Tuple[dict, int]:
req = {
"command": "depsolve",
"arch": ARCH,
@ -53,12 +54,15 @@ def depsolve(transactions, repos, root_dir, cache_dir, dnf_config, opt_metadata)
}
}
if with_sbom:
req["arguments"]["sbom"] = {"type": "spdx"}
# If there is a config file, write it to a temporary file and pass it to the depsolver
with TemporaryDirectory() as cfg_dir:
env = None
if dnf_config:
cfg_file = pathlib.Path(cfg_dir) / "solver.json"
cfg_file.write_text(dnf_config)
json.dump(dnf_config, cfg_file.open("w"))
env = {"OSBUILD_SOLVER_CONFIG": os.fspath(cfg_file)}
p = sp.run(["./tools/osbuild-depsolve-dnf"], input=json.dumps(req), env=env,
@ -1240,13 +1244,15 @@ def config_combos(tmp_path, servers):
yield repo_configs, os.fspath(root_dir), opt_metadata
# pylint: disable=too-many-branches
@pytest.mark.parametrize("test_case", depsolve_test_cases, ids=tcase_idfn)
@pytest.mark.parametrize("with_sbom", [False, True])
@pytest.mark.parametrize("dnf_config, detect_fn", [
(None, assert_dnf),
('{"use_dnf5": false}', assert_dnf),
('{"use_dnf5": true}', assert_dnf5),
({}, assert_dnf),
({"use_dnf5": False}, assert_dnf),
({"use_dnf5": True}, assert_dnf5),
], ids=["no-config", "dnf4", "dnf5"])
def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case):
def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, with_sbom, test_case):
try:
detect_fn()
except RuntimeError as e:
@ -1260,7 +1266,7 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case):
"error_pkg_not_in_enabled_repos",
]
if dnf_config == '{"use_dnf5": true}' and test_case["id"] in dnf5_broken_test_cases:
if dnf_config.get("use_dnf5", False) and test_case["id"] in dnf5_broken_test_cases:
pytest.skip("This test case is known to be broken with dnf5")
transactions = test_case["transactions"]
@ -1271,7 +1277,15 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case):
for repo_configs, root_dir, opt_metadata in config_combos(tmp_path, repo_servers_copy):
with TemporaryDirectory() as cache_dir:
res, exit_code = depsolve(transactions, repo_configs, root_dir, cache_dir, dnf_config, opt_metadata)
res, exit_code = depsolve(transactions, repo_configs, root_dir,
cache_dir, dnf_config, opt_metadata, with_sbom)
# NB: dnf5 implementation does not support SBOM yet
if dnf_config.get("use_dnf5", False) and with_sbom:
assert exit_code != 0
assert res["kind"] == "InvalidRequest"
assert res["reason"] == "SBOM support for DNF5 is not implemented"
continue
if test_case.get("error", False):
assert exit_code != 0
@ -1285,6 +1299,20 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case):
for repo in res["repos"].values():
assert repo["gpgkeys"] == [TEST_KEY + repo["id"]]
assert repo["sslverify"] is False
if with_sbom:
assert "sbom" in res
spdx_2_3_1_schema_file = './test/data/spdx/spdx-schema-v2.3.1.json'
with open(spdx_2_3_1_schema_file, encoding="utf-8") as f:
spdx_schema = json.load(f)
validator = jsonschema.Draft4Validator
validator.check_schema(spdx_schema)
spdx_validator = validator(spdx_schema)
spdx_validator.validate(res["sbom"])
assert {pkg["name"] for pkg in res["sbom"]["packages"]} == test_case["results"]["packages"]
else:
assert "sbom" not in res
# if opt_metadata includes 'filelists', then each repository 'repodata' must include a file that matches
# *filelists*
@ -1294,10 +1322,7 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case):
else:
assert n_filelist_files == 0
if dnf_config:
use_dnf5 = json.loads(dnf_config)["use_dnf5"]
else:
use_dnf5 = False
use_dnf5 = dnf_config.get("use_dnf5", False)
if use_dnf5:
assert res["solver"] == "dnf5"
else: