From bca1f1612cac10719713ec3460990d1d53db030c Mon Sep 17 00:00:00 2001 From: Pedro Brochado Date: Tue, 19 May 2026 14:34:43 -0300 Subject: [PATCH 1/7] Add vulnerability report support for rpm ecosystems --- CHANGES/+vuln-report.feature | 1 + pulp_rpm/app/constants.py | 13 ++ pulp_rpm/app/viewsets/repository.py | 30 +++- pulp_rpm/app/vuln_report.py | 156 ++++++++++++++++++ .../tests/functional/api/test_vuln_report.py | 127 ++++++++++++++ 5 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 CHANGES/+vuln-report.feature create mode 100644 pulp_rpm/app/vuln_report.py create mode 100644 pulp_rpm/tests/functional/api/test_vuln_report.py diff --git a/CHANGES/+vuln-report.feature b/CHANGES/+vuln-report.feature new file mode 100644 index 000000000..d740bd9e4 --- /dev/null +++ b/CHANGES/+vuln-report.feature @@ -0,0 +1 @@ +Added a `vulnerability_report` action to `RpmRepositoryVersionViewSet` that scans all RPM packages in a repository version for known CVEs via osv.dev. Repositories are opted in by setting the `osv.rpm.ecosystem` label (e.g., `Red Hat`); `osv.rpm.redhat.cpes` is an optional label to narrow the query to specific CPEs. \ No newline at end of file diff --git a/pulp_rpm/app/constants.py b/pulp_rpm/app/constants.py index 01dabfeb4..79d3d6d47 100644 --- a/pulp_rpm/app/constants.py +++ b/pulp_rpm/app/constants.py @@ -288,6 +288,19 @@ PACKAGES_DIRECTORY = "Packages" DIST_TREE_MAIN_REPO_PATH = "." +LABEL_OSV_CONFIG = "osv.rpm.config" +SUPPORTED_ECOSYSTEMS = { + "AlmaLinux", + "Azure Linux", + "Mageia", + "openEuler", + "openSUSE", + "Photon OS", + "Red Hat", + "Rocky Linux", + "SUSE", +} + # Mappings of the possible integer values of "sum_type" on Advisory packages to their user-facing # string representation. Should mirror the createrepo_c source code: # https://github.com/rpm-software-management/createrepo_c/blob/master/src/checksum.h#L43-L54 diff --git a/pulp_rpm/app/viewsets/repository.py b/pulp_rpm/app/viewsets/repository.py index 1f1e65847..0e17f5997 100644 --- a/pulp_rpm/app/viewsets/repository.py +++ b/pulp_rpm/app/viewsets/repository.py @@ -11,7 +11,7 @@ AsyncOperationResponseSerializer, RepositoryAddRemoveContentSerializer, ) -from pulpcore.plugin.tasking import dispatch +from pulpcore.plugin.tasking import check_content, dispatch from pulpcore.plugin.util import extract_pk from pulpcore.plugin.viewsets import ( DistributionViewSet, @@ -26,6 +26,7 @@ from pulp_rpm.app import tasks from pulp_rpm.app.constants import SYNC_POLICIES +from pulp_rpm.app.vuln_report import generate_vuln_report_payloads, parse_osv_labels from pulp_rpm.app.models import ( RpmDistribution, RpmPublication, @@ -318,9 +319,36 @@ class RpmRepositoryVersionViewSet(RepositoryVersionViewSet): "has_repository_model_or_domain_or_obj_perms:rpm.view_rpmrepository", ], }, + { + "action": ["vulnerability_report"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_repository_model_or_domain_or_obj_perms:rpm.view_rpmrepository", + }, ], } + @extend_schema( + description="Dispatch a task to scan all packages in this repository version for known CVEs via osv.dev.", + responses={202: AsyncOperationResponseSerializer}, + ) + @action(detail=True, methods=["post"]) + def vulnerability_report(self, request, pk): + repository_version = self.get_object() + repo = repository_version.repository + + parse_osv_labels(repo.pulp_labels) + + async_result = dispatch( + check_content, + shared_resources=[repo], + kwargs={ + "func": f"{generate_vuln_report_payloads.__module__}.{generate_vuln_report_payloads.__name__}", + "args": [str(repository_version.pk)], + }, + ) + return OperationPostponedResponse(async_result, request) + class RpmRemoteViewSet(RemoteViewSet, RolesMixin): """ diff --git a/pulp_rpm/app/vuln_report.py b/pulp_rpm/app/vuln_report.py new file mode 100644 index 000000000..3604cc9d9 --- /dev/null +++ b/pulp_rpm/app/vuln_report.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import re +from collections.abc import AsyncGenerator, Generator +from gettext import gettext as _ +from typing import Any, TypedDict, cast + +from asgiref.sync import sync_to_async +from rest_framework import serializers +from rest_framework.serializers import ValidationError as DRFValidationError + +from pulpcore.plugin.models import RepositoryVersion # type: ignore[import-untyped] + +from pulp_rpm.app.constants import LABEL_OSV_CONFIG, SUPPORTED_ECOSYSTEMS +from pulp_rpm.app.models import Package + + +class OsvPackage(TypedDict): + name: str + ecosystem: str + + +class OsvQuery(TypedDict): + version: str + package: OsvPackage + + +class VulnReportPayload(OsvQuery): + """Format required by pulpcore's Vulnerability Report feature.""" + + content: Any + repo_version: RepositoryVersion + + +class OsvEcosystem(TypedDict): + name: str + extra_arguments: dict[str, Any] + + +# --- Serializers --- + + +class EcosystemConfigSerializer(serializers.Serializer): + name = serializers.CharField() + + def validate_name(self, value: str) -> str: + if value not in SUPPORTED_ECOSYSTEMS: + supported = ", ".join(sorted(SUPPORTED_ECOSYSTEMS)) + raise serializers.ValidationError( + _("Unsupported ecosystem '%s'. Supported: %s.") % (value, supported) + ) + return value + + +class RedHatEcosystemConfigSerializer(EcosystemConfigSerializer): + cpes = serializers.ListField(child=serializers.CharField(), required=False, default=list) + + +_ECOSYSTEM_SERIALIZERS: dict[str, type[EcosystemConfigSerializer]] = { + "Red Hat": RedHatEcosystemConfigSerializer, +} + + +class OsvConfigSerializer(serializers.Serializer): + """Deserializes the osv.rpm.config label value into a list of OsvEcosystem entries.""" + + config = serializers.JSONField() + + def validate_config(self, value: Any) -> list[OsvEcosystem]: + if value is None: + raise DRFValidationError( + {LABEL_OSV_CONFIG: _("Required label '%s' is missing.") % LABEL_OSV_CONFIG} + ) + if not isinstance(value, list) or not value: + raise serializers.ValidationError(_("Must be a non-empty JSON list.")) + + result: list[OsvEcosystem] = [] + for i, item in enumerate(value): + if not isinstance(item, dict) or "name" not in item: + raise serializers.ValidationError( + _("Item %d: each entry must be an object with a 'name' field.") % i + ) + name = item["name"] + serializer_class = _ECOSYSTEM_SERIALIZERS.get(name, EcosystemConfigSerializer) + s = serializer_class(data=item) + if not s.is_valid(): + raise serializers.ValidationError({f"item[{i}]": s.errors}) + data = cast(dict[str, Any], s.validated_data) + extra = {k: v for k, v in data.items() if k != "name"} + result.append(OsvEcosystem(name=data["name"], extra_arguments=extra)) + return result + + +# --- Helpers --- + + +_CPE_PREFIX = re.compile(r"^cpe:/[oa]:redhat") + + +def parse_osv_labels(labels: dict[str, str]) -> list[OsvEcosystem]: + """Parse and validate the osv.rpm.config label. + + Returns a list of OsvEcosystem entries. Raises DRFValidationError if absent or malformed. + """ + raw = labels.get(LABEL_OSV_CONFIG, None) + s = OsvConfigSerializer(data={"config": raw}) + s.is_valid(raise_exception=True) + return s.validated_data["config"] + + +def build_osv_queries( + name: str, version: str, ecosystems: list[OsvEcosystem] +) -> Generator[OsvQuery, None, None]: + """Yield OSV query dicts for the given package and ecosystems. + + For Red Hat entries with CPEs, each CPE is converted to an ecosystem string. + For all other entries, the ecosystem name is used directly. + """ + for ecosystem in ecosystems: + cpes: list[str] = ecosystem["extra_arguments"].get("cpes", []) + if cpes: + for cpe in cpes: + yield OsvQuery( + version=version, + package=OsvPackage( + name=name, ecosystem=_CPE_PREFIX.sub(ecosystem["name"], cpe) + ), + ) + else: + yield OsvQuery( + version=version, + package=OsvPackage(name=name, ecosystem=ecosystem["name"]), + ) + + +async def generate_vuln_report_payloads( + repository_version_pk: str, +) -> AsyncGenerator[VulnReportPayload, None]: + """Generator of OSV query dicts for rpm.packages in a repository version.""" + repo_version: RepositoryVersion = await RepositoryVersion.objects.aget( + pk=repository_version_pk + ) + repo: Any = await sync_to_async(lambda: repo_version.repository)() + labels: dict[str, str] = await sync_to_async(lambda: dict(repo.pulp_labels))() + ecosystems = parse_osv_labels(labels) + + pkg_content = repo_version.content.filter(pulp_type="rpm.package") + async for content in pkg_content.aiterator(): + pkg = await Package.objects.only("name", "version").aget(pk=content.pk) + for osv_data in build_osv_queries(str(pkg.name), str(pkg.version), ecosystems): + yield VulnReportPayload( + version=osv_data["version"], + package=osv_data["package"], + content=content, + repo_version=repo_version, + ) diff --git a/pulp_rpm/tests/functional/api/test_vuln_report.py b/pulp_rpm/tests/functional/api/test_vuln_report.py new file mode 100644 index 000000000..56a29920f --- /dev/null +++ b/pulp_rpm/tests/functional/api/test_vuln_report.py @@ -0,0 +1,127 @@ +"""Functional tests for the vulnerability_report action on RpmRepositoryVersionViewSet.""" +from tempfile import NamedTemporaryFile + +import pytest +import requests as http_requests + +from pulp_rpm.tests.functional.constants import RPM_SIGNED_URL + +# CentOS 7.0 vault kernel package — chosen because it has known advisories in OSV/Red Hat. +KERNEL_RPM_URL = ( + "https://vault.centos.org/7.0.1406/os/x86_64/Packages/kernel-3.10.0-123.el7.x86_64.rpm" +) +EXPECTED_RHSA_IDS = [ + "RHSA-2014:0678", + "RHSA-2014:0786", + "RHSA-2014:0923", + "RHSA-2014:1023", + "RHSA-2014:1281", + "RHSA-2014:1724", + "RHSA-2014:1971", + "RHSA-2014:2010", +] + +LABEL_OSV_CONFIG = "osv.rpm.config" + +REDHAT_WITH_CPE = '[{"name": "Red Hat", "cpes": ["cpe:/o:redhat:enterprise_linux:7::workstation"]}]' +REDHAT_NO_CPE = '[{"name": "Red Hat"}]' + + +def _upload_rpm(rpm_package_api, monitor_task, repo_href, url): + with NamedTemporaryFile() as tmp: + tmp.write(http_requests.get(url).content) + tmp.flush() + monitor_task(rpm_package_api.create(file=tmp.name, repository=repo_href).task) + + +def _post_vuln_report(pulp_requests, version_href): + return pulp_requests.post(f"{version_href}vulnerability_report/") + + +def _collect_vuln_ids(pulp_requests, version_href): + resp = pulp_requests.get(version_href) + resp.raise_for_status() + vuln_report_url = resp.json()["vuln_report"] + results = pulp_requests.get(vuln_report_url).json()["results"] + return {vuln["id"] for report in results for vuln in report["vulns"]} + + +@pytest.fixture +def repo_with_kernel(rpm_repository_factory, rpm_repository_api, rpm_package_api, monitor_task): + def _factory(labels): + repo = rpm_repository_factory(pulp_labels=labels) + _upload_rpm(rpm_package_api, monitor_task, repo.pulp_href, KERNEL_RPM_URL) + return rpm_repository_api.read(repo.pulp_href) + + return _factory + + +@pytest.fixture +def repo_with_small_rpm( + rpm_repository_factory, rpm_repository_api, rpm_package_api, monitor_task +): + def _factory(labels): + repo = rpm_repository_factory(pulp_labels=labels) + _upload_rpm(rpm_package_api, monitor_task, repo.pulp_href, RPM_SIGNED_URL) + return rpm_repository_api.read(repo.pulp_href) + + return _factory + + +def test_vuln_report_redhat_with_cpe( + repo_with_kernel, + monitor_task, + pulp_requests, +): + """Red Hat ecosystem + CPE: known RHSA IDs appear in the report.""" + repo = repo_with_kernel({LABEL_OSV_CONFIG: REDHAT_WITH_CPE}) + + resp = _post_vuln_report(pulp_requests, repo.latest_version_href) + assert resp.status_code == 202, resp.text + monitor_task(resp.json()["task"]) + + ids = _collect_vuln_ids(pulp_requests, repo.latest_version_href) + assert set(EXPECTED_RHSA_IDS).issubset(ids) + + +def test_vuln_report_redhat_without_cpe( + repo_with_kernel, + monitor_task, + pulp_requests, +): + """Red Hat ecosystem without CPE: falls back to 'Red Hat' and still returns results.""" + repo = repo_with_kernel({LABEL_OSV_CONFIG: REDHAT_NO_CPE}) + + resp = _post_vuln_report(pulp_requests, repo.latest_version_href) + assert resp.status_code == 202, resp.text + monitor_task(resp.json()["task"]) + + ids = _collect_vuln_ids(pulp_requests, repo.latest_version_href) + assert len(ids) > 0 + + +def test_vuln_report_missing_config(repo_with_small_rpm, pulp_requests): + """Missing osv.rpm.config label returns HTTP 400.""" + repo = repo_with_small_rpm({}) + + resp = _post_vuln_report(pulp_requests, repo.latest_version_href) + assert resp.status_code == 400 + assert LABEL_OSV_CONFIG in resp.json() + + +def test_vuln_report_unsupported_ecosystem(repo_with_small_rpm, pulp_requests): + """Unsupported ecosystem name returns HTTP 400.""" + repo = repo_with_small_rpm({LABEL_OSV_CONFIG: '[{"name": "NotAnEcosystem"}]'}) + + resp = _post_vuln_report(pulp_requests, repo.latest_version_href) + assert resp.status_code == 400 + assert LABEL_OSV_CONFIG in resp.json() + + +def test_vuln_report_malformed_config(repo_with_small_rpm, pulp_requests): + """Malformed osv.rpm.config JSON returns HTTP 400.""" + repo = repo_with_small_rpm({LABEL_OSV_CONFIG: "not-valid-json["}) + + resp = _post_vuln_report(pulp_requests, repo.latest_version_href) + assert resp.status_code == 400 + assert LABEL_OSV_CONFIG in resp.json() From 64c0b6030c8ed1b09e61c004fd6b341f2e15bc61 Mon Sep 17 00:00:00 2001 From: Pedro Brochado Date: Tue, 19 May 2026 17:21:27 -0300 Subject: [PATCH 2/7] wip --- pulp_rpm/app/viewsets/repository.py | 4 +- .../tests/functional/api/test_vuln_report.py | 171 ++++++++---------- pulp_rpm/tests/functional/conftest.py | 6 + 3 files changed, 88 insertions(+), 93 deletions(-) diff --git a/pulp_rpm/app/viewsets/repository.py b/pulp_rpm/app/viewsets/repository.py index 0e17f5997..23c83c00c 100644 --- a/pulp_rpm/app/viewsets/repository.py +++ b/pulp_rpm/app/viewsets/repository.py @@ -26,7 +26,6 @@ from pulp_rpm.app import tasks from pulp_rpm.app.constants import SYNC_POLICIES -from pulp_rpm.app.vuln_report import generate_vuln_report_payloads, parse_osv_labels from pulp_rpm.app.models import ( RpmDistribution, RpmPublication, @@ -44,6 +43,7 @@ UlnRemoteSerializer, ) from pulp_rpm.app.tasks.signing import signed_add_and_remove +from pulp_rpm.app.vuln_report import generate_vuln_report_payloads, parse_osv_labels class RpmModifyRepositoryActionMixin(ModifyRepositoryActionMixin): @@ -333,7 +333,7 @@ class RpmRepositoryVersionViewSet(RepositoryVersionViewSet): responses={202: AsyncOperationResponseSerializer}, ) @action(detail=True, methods=["post"]) - def vulnerability_report(self, request, pk): + def vulnerability_report(self, request, repository_pk, **kwargs): repository_version = self.get_object() repo = repository_version.repository diff --git a/pulp_rpm/tests/functional/api/test_vuln_report.py b/pulp_rpm/tests/functional/api/test_vuln_report.py index 56a29920f..2fe0cfe18 100644 --- a/pulp_rpm/tests/functional/api/test_vuln_report.py +++ b/pulp_rpm/tests/functional/api/test_vuln_report.py @@ -1,15 +1,10 @@ """Functional tests for the vulnerability_report action on RpmRepositoryVersionViewSet.""" -from tempfile import NamedTemporaryFile - import pytest -import requests as http_requests +import rpm_rs -from pulp_rpm.tests.functional.constants import RPM_SIGNED_URL +from pulpcore.client.pulp_rpm import RepositoryVersion +from pulpcore.client.pulp_rpm.exceptions import ApiException -# CentOS 7.0 vault kernel package — chosen because it has known advisories in OSV/Red Hat. -KERNEL_RPM_URL = ( - "https://vault.centos.org/7.0.1406/os/x86_64/Packages/kernel-3.10.0-123.el7.x86_64.rpm" -) EXPECTED_RHSA_IDS = [ "RHSA-2014:0678", "RHSA-2014:0786", @@ -23,105 +18,99 @@ LABEL_OSV_CONFIG = "osv.rpm.config" -REDHAT_WITH_CPE = '[{"name": "Red Hat", "cpes": ["cpe:/o:redhat:enterprise_linux:7::workstation"]}]' -REDHAT_NO_CPE = '[{"name": "Red Hat"}]' - - -def _upload_rpm(rpm_package_api, monitor_task, repo_href, url): - with NamedTemporaryFile() as tmp: - tmp.write(http_requests.get(url).content) - tmp.flush() - monitor_task(rpm_package_api.create(file=tmp.name, repository=repo_href).task) - - -def _post_vuln_report(pulp_requests, version_href): - return pulp_requests.post(f"{version_href}vulnerability_report/") - - -def _collect_vuln_ids(pulp_requests, version_href): - resp = pulp_requests.get(version_href) - resp.raise_for_status() - vuln_report_url = resp.json()["vuln_report"] - results = pulp_requests.get(vuln_report_url).json()["results"] - return {vuln["id"] for report in results for vuln in report["vulns"]} - - -@pytest.fixture -def repo_with_kernel(rpm_repository_factory, rpm_repository_api, rpm_package_api, monitor_task): - def _factory(labels): - repo = rpm_repository_factory(pulp_labels=labels) - _upload_rpm(rpm_package_api, monitor_task, repo.pulp_href, KERNEL_RPM_URL) - return rpm_repository_api.read(repo.pulp_href) - - return _factory - @pytest.fixture -def repo_with_small_rpm( - rpm_repository_factory, rpm_repository_api, rpm_package_api, monitor_task +def repo_with_kernel( + rpm_repository_factory, rpm_repository_api, rpm_package_api, monitor_task, tmp_path ): def _factory(labels): repo = rpm_repository_factory(pulp_labels=labels) - _upload_rpm(rpm_package_api, monitor_task, repo.pulp_href, RPM_SIGNED_URL) + pkg = ( + rpm_rs.PackageBuilder("kernel", "3.10.0", "GPLv2", "x86_64") + .release("123.el7") + .build() + ) + rpm_path = tmp_path / "kernel-3.10.0-123.el7.x86_64.rpm" + pkg.write_file(rpm_path) + monitor_task(rpm_package_api.create(file=str(rpm_path), repository=repo.pulp_href).task) return rpm_repository_api.read(repo.pulp_href) return _factory -def test_vuln_report_redhat_with_cpe( +@pytest.mark.parametrize( + "labels", + [ + pytest.param( + {LABEL_OSV_CONFIG: '[{"name": "Red Hat", "cpes": ["cpe:/o:redhat:enterprise_linux:7::workstation"]}]'}, + id="redhat_with_cpe", + ), + pytest.param( + {LABEL_OSV_CONFIG: '[{"name": "Red Hat"}]'}, + id="redhat_without_cpe", + ), + ], +) +def test_vuln_report_redhat( + labels, repo_with_kernel, monitor_task, - pulp_requests, + rpm_repository_versions_api, + pulpcore_bindings, ): - """Red Hat ecosystem + CPE: known RHSA IDs appear in the report.""" - repo = repo_with_kernel({LABEL_OSV_CONFIG: REDHAT_WITH_CPE}) + """Known RHSA IDs appear in the report for both CPE and non-CPE Red Hat configs.""" + repo = repo_with_kernel(labels) - resp = _post_vuln_report(pulp_requests, repo.latest_version_href) - assert resp.status_code == 202, resp.text - monitor_task(resp.json()["task"]) + resp = rpm_repository_versions_api.vulnerability_report( + repo.latest_version_href, RepositoryVersion() + ) + monitor_task(resp.task) - ids = _collect_vuln_ids(pulp_requests, repo.latest_version_href) + results = pulpcore_bindings.VulnReportApi.list(repo_versions=repo.latest_version_href).results + ids = {vuln["id"] for report in results for vuln in report.vulns} assert set(EXPECTED_RHSA_IDS).issubset(ids) -def test_vuln_report_redhat_without_cpe( - repo_with_kernel, - monitor_task, - pulp_requests, +@pytest.mark.parametrize( + "labels,expected", + [ + pytest.param( + {}, + "Required label", + id="missing_config", + ), + pytest.param( + {LABEL_OSV_CONFIG: '[{"name": "NotAnEcosystem"}]'}, + "Unsupported ecosystem", + id="unsupported_ecosystem", + ), + pytest.param( + {LABEL_OSV_CONFIG: "not-valid-json["}, + "valid JSON", + id="malformed_config", + ), + pytest.param( + {LABEL_OSV_CONFIG: ""}, + "valid JSON", + id="empty_value", + ), + pytest.param( + {LABEL_OSV_CONFIG: " "}, + "valid JSON", + id="whitespace_only", + ), + ], +) +def test_vuln_report_invalid_config( + labels, expected, rpm_repository_factory, rpm_repository_versions_api ): - """Red Hat ecosystem without CPE: falls back to 'Red Hat' and still returns results.""" - repo = repo_with_kernel({LABEL_OSV_CONFIG: REDHAT_NO_CPE}) - - resp = _post_vuln_report(pulp_requests, repo.latest_version_href) - assert resp.status_code == 202, resp.text - monitor_task(resp.json()["task"]) - - ids = _collect_vuln_ids(pulp_requests, repo.latest_version_href) - assert len(ids) > 0 - - -def test_vuln_report_missing_config(repo_with_small_rpm, pulp_requests): - """Missing osv.rpm.config label returns HTTP 400.""" - repo = repo_with_small_rpm({}) - - resp = _post_vuln_report(pulp_requests, repo.latest_version_href) - assert resp.status_code == 400 - assert LABEL_OSV_CONFIG in resp.json() - - -def test_vuln_report_unsupported_ecosystem(repo_with_small_rpm, pulp_requests): - """Unsupported ecosystem name returns HTTP 400.""" - repo = repo_with_small_rpm({LABEL_OSV_CONFIG: '[{"name": "NotAnEcosystem"}]'}) - - resp = _post_vuln_report(pulp_requests, repo.latest_version_href) - assert resp.status_code == 400 - assert LABEL_OSV_CONFIG in resp.json() - - -def test_vuln_report_malformed_config(repo_with_small_rpm, pulp_requests): - """Malformed osv.rpm.config JSON returns HTTP 400.""" - repo = repo_with_small_rpm({LABEL_OSV_CONFIG: "not-valid-json["}) - - resp = _post_vuln_report(pulp_requests, repo.latest_version_href) - assert resp.status_code == 400 - assert LABEL_OSV_CONFIG in resp.json() + """Invalid osv.rpm.config label returns HTTP 400.""" + repo = rpm_repository_factory(pulp_labels=labels) + + with pytest.raises(ApiException) as exc: + rpm_repository_versions_api.vulnerability_report( + repo.latest_version_href, RepositoryVersion() + ) + assert exc.value.status == 400 + assert LABEL_OSV_CONFIG in exc.value.body + assert expected in exc.value.body diff --git a/pulp_rpm/tests/functional/conftest.py b/pulp_rpm/tests/functional/conftest.py index 23237178e..5e7b74f44 100644 --- a/pulp_rpm/tests/functional/conftest.py +++ b/pulp_rpm/tests/functional/conftest.py @@ -21,6 +21,7 @@ ContentPackagelangpacksApi, ContentPackagesApi, RemotesUlnApi, + RepositoriesRpmVersionsApi, RpmCompsApi, RpmCopyApi, RpmRepositorySyncURL, @@ -128,6 +129,11 @@ def rpm_copy_api(rpm_client): return RpmCopyApi(rpm_client) +@pytest.fixture(scope="session") +def rpm_repository_versions_api(rpm_client): + return RepositoriesRpmVersionsApi(rpm_client) + + @pytest.fixture def signed_artifact(pulpcore_bindings, tmp_path): data = requests.get(RPM_SIGNED_URL).content From 4a895446661c7174b5ad70cacb71d9ece6714a8b Mon Sep 17 00:00:00 2001 From: Pedro Brochado Date: Wed, 20 May 2026 09:44:50 -0300 Subject: [PATCH 3/7] Improve repository builder test util --- .../tests/functional/api/test_vuln_report.py | 13 ++- pulp_rpm/tests/functional/utils.py | 89 ++++++++++++------- 2 files changed, 63 insertions(+), 39 deletions(-) diff --git a/pulp_rpm/tests/functional/api/test_vuln_report.py b/pulp_rpm/tests/functional/api/test_vuln_report.py index 2fe0cfe18..008af6b10 100644 --- a/pulp_rpm/tests/functional/api/test_vuln_report.py +++ b/pulp_rpm/tests/functional/api/test_vuln_report.py @@ -1,10 +1,11 @@ """Functional tests for the vulnerability_report action on RpmRepositoryVersionViewSet.""" import pytest -import rpm_rs from pulpcore.client.pulp_rpm import RepositoryVersion from pulpcore.client.pulp_rpm.exceptions import ApiException +from pulp_rpm.tests.functional.utils import MetaPackage, Nevra + EXPECTED_RHSA_IDS = [ "RHSA-2014:0678", "RHSA-2014:0786", @@ -25,13 +26,9 @@ def repo_with_kernel( ): def _factory(labels): repo = rpm_repository_factory(pulp_labels=labels) - pkg = ( - rpm_rs.PackageBuilder("kernel", "3.10.0", "GPLv2", "x86_64") - .release("123.el7") - .build() - ) - rpm_path = tmp_path / "kernel-3.10.0-123.el7.x86_64.rpm" - pkg.write_file(rpm_path) + nevra = Nevra(name="kernel", epoch="0", version="3.10.0", release="123.el7", arch="x86_64") + rpm_path = tmp_path / f"{nevra.to_nvra()}.rpm" + MetaPackage(nevra=nevra).write_file(rpm_path) monitor_task(rpm_package_api.create(file=str(rpm_path), repository=repo.pulp_href).task) return rpm_repository_api.read(repo.pulp_href) diff --git a/pulp_rpm/tests/functional/utils.py b/pulp_rpm/tests/functional/utils.py index 36d48e79e..dbabdbd17 100644 --- a/pulp_rpm/tests/functional/utils.py +++ b/pulp_rpm/tests/functional/utils.py @@ -15,6 +15,7 @@ import createrepo_c as cr import pyzstd import requests +import rpm_rs from pulp_rpm.tests.functional.constants import ( LEGACY_SIGNING_KEY, @@ -130,9 +131,10 @@ class MetaPackage: """Simplified package representation.""" nevra: Nevra - digest: str - time_build: int - location: str + digest: str = "" + time_build: int = 0 + location: str = "" + metadata_only: bool = False @classmethod def generate_nevra(cls, n: int) -> Nevra: @@ -148,6 +150,18 @@ def generate_nevra(cls, n: int) -> Nevra: def generate_digest(cls, n: int) -> str: return hashlib.sha256(f"digest-{SALT}-{n}".encode()).hexdigest() + def write_file(self, path: Path) -> None: + built = ( + rpm_rs.PackageBuilder( + self.nevra.name, self.nevra.version, "GPLv2", self.nevra.arch + ) + .release(self.nevra.release) + .epoch(self.nevra.epoch) + .build() + ) + built.write_file(path) + self.digest = hashlib.sha256(path.read_bytes()).hexdigest() + def normalized_location(pkg: MetaPackage, prefix: bool = True) -> MetaPackage: """Return a copy of pkg with location set to the canonical NVRA filename.""" @@ -163,20 +177,23 @@ class RemoteRepository: class PackageList(list[MetaPackage]): - """Parsed package list from an RPM repository. Behaves as a list of MetaPackage.""" + """Parsed package list from an RPM repository.""" def filter(self, name: str) -> "PackageList": return PackageList(p for p in self if p.nevra.name == name) class PackageListFetcher: - """Builds PackageList instances; wires in the packages API for Pulp-side queries.""" + """Inspect packages from a generic RPM repo (through metadata) or from Pulp repositories.""" def __init__(self, rpm_package_api): self._rpm_package_api = rpm_package_api def from_repository_metadata(self, url: str) -> PackageList: - """Build from a file:// or http(s):// URL pointing to an RPM repository.""" + """Inspect what is in a RPM published metadata (Pulp's or not). + + Build from a file:// or http(s):// URL pointing to an RPM repository. + """ if url.startswith("file://"): repodata = Path(url[len("file://") :]) / "repodata" primary = next(repodata.glob("*primary.xml*")) @@ -184,7 +201,10 @@ def from_repository_metadata(self, url: str) -> PackageList: return self._from_http_url(url) def from_pulp_repoversion(self, repoversion_href: str) -> PackageList: - """Build from a Pulp repository version using the packages API.""" + """Inspect what is in a Pulp repository. + + Build from a Pulp repository version using the packages API. + """ response = self._rpm_package_api.list(repository_version=repoversion_href, limit=1000) packages = [ MetaPackage( @@ -240,7 +260,7 @@ def _from_http_url(base_url: str) -> PackageList: class RepositoryBuilder: - """Builds local RPM repositories from MetaPackage entries using createrepo_c.""" + """Builds a fixture RPM repository from MetaPackage declarative entries.""" def __init__(self, tmp_path: Path): self._tmp_path = tmp_path @@ -254,29 +274,36 @@ def build( cr_packages = [] for pkg in packages: - cr_pkg = cr.Package() - cr_pkg.name = pkg.nevra.name - cr_pkg.arch = pkg.nevra.arch - cr_pkg.epoch = pkg.nevra.epoch - cr_pkg.version = pkg.nevra.version - cr_pkg.release = pkg.nevra.release - cr_pkg.pkgId = pkg.digest - cr_pkg.checksum_type = "sha256" - cr_pkg.location_href = pkg.location - cr_pkg.summary = f"Headless package {pkg.nevra.name}" - cr_pkg.description = "" - cr_pkg.size_package = 0 - cr_pkg.size_installed = 0 - cr_pkg.size_archive = 0 - cr_pkg.time_file = 0 - cr_pkg.time_build = pkg.time_build - cr_pkg.rpm_header_start = 0 - cr_pkg.rpm_header_end = 0 - cr_pkg.rpm_license = "" - cr_pkg.rpm_vendor = "" - cr_pkg.rpm_group = "" - cr_pkg.rpm_buildhost = "" - cr_pkg.rpm_sourcerpm = "" + if pkg.metadata_only: + cr_pkg = cr.Package() + cr_pkg.name = pkg.nevra.name + cr_pkg.arch = pkg.nevra.arch + cr_pkg.epoch = pkg.nevra.epoch + cr_pkg.version = pkg.nevra.version + cr_pkg.release = pkg.nevra.release + cr_pkg.pkgId = pkg.digest + cr_pkg.checksum_type = "sha256" + cr_pkg.location_href = pkg.location + cr_pkg.summary = f"Headless package {pkg.nevra.name}" + cr_pkg.description = "" + cr_pkg.size_package = 0 + cr_pkg.size_installed = 0 + cr_pkg.size_archive = 0 + cr_pkg.time_file = 0 + cr_pkg.time_build = pkg.time_build + cr_pkg.rpm_header_start = 0 + cr_pkg.rpm_header_end = 0 + cr_pkg.rpm_license = "" + cr_pkg.rpm_vendor = "" + cr_pkg.rpm_group = "" + cr_pkg.rpm_buildhost = "" + cr_pkg.rpm_sourcerpm = "" + else: + rpm_path = repo_dir / pkg.location + rpm_path.parent.mkdir(parents=True, exist_ok=True) + pkg.write_file(rpm_path) + cr_pkg = cr.package_from_rpm(str(rpm_path)) + cr_pkg.location_href = pkg.location cr_packages.append(cr_pkg) with cr.RepositoryWriter(str(repo_dir), compression=cr.NO_COMPRESSION) as writer: From a3d336d4df009b7018d8c7aeef9a32b38a9b4d95 Mon Sep 17 00:00:00 2001 From: Pedro Brochado Date: Mon, 1 Jun 2026 15:31:44 -0300 Subject: [PATCH 4/7] wip --- pulp_rpm/app/serializers/repository.py | 40 +++++++++++++++++++ pulp_rpm/app/viewsets/repository.py | 2 +- pulp_rpm/app/vuln_report.py | 27 ++++++++----- .../tests/functional/api/test_vuln_report.py | 36 ++++++++++------- pulp_rpm/tests/functional/utils.py | 32 +++++++-------- pulp_rpm/tests/unit/test_serializers.py | 24 +++++++++++ 6 files changed, 118 insertions(+), 43 deletions(-) create mode 100644 pulp_rpm/tests/unit/test_serializers.py diff --git a/pulp_rpm/app/serializers/repository.py b/pulp_rpm/app/serializers/repository.py index f7bc3cd3a..f3ecb37c7 100644 --- a/pulp_rpm/app/serializers/repository.py +++ b/pulp_rpm/app/serializers/repository.py @@ -1,3 +1,5 @@ +import json +import urllib.parse from gettext import gettext as _ from textwrap import dedent from urllib.parse import urlparse @@ -33,6 +35,7 @@ ALLOWED_PUBLISH_CHECKSUMS, CHECKSUM_CHOICES, COMPRESSION_CHOICES, + LABEL_OSV_CONFIG, LAYOUT_CHOICES, SKIP_TYPES, SYNC_POLICY_CHOICES, @@ -51,6 +54,19 @@ ALLOWED_CONTENT_CHECKSUMS = settings.ALLOWED_CONTENT_CHECKSUMS +class OsvConfigField(serializers.JSONField): + """JSONField backed by the osv.rpm.config label; reads from labels on the instance.""" + + def get_attribute(self, instance): + raw = dict(instance.pulp_labels).get(LABEL_OSV_CONFIG) + if raw is None: + return None + try: + return json.loads(urllib.parse.unquote(raw)) + except (json.JSONDecodeError, ValueError): + return None + + @extend_schema_serializer( deprecate_fields=[ "metadata_checksum_type", @@ -178,6 +194,14 @@ class RpmRepositorySerializer(RepositorySerializer): "A JSON document describing the config.repo file Pulp should generate for this repo" ), ) + osv_config = OsvConfigField( + required=False, + allow_null=True, + help_text=_( + "OSV vulnerability scanning configuration. A list of ecosystem entries, each with a " + "'name' field (e.g. 'Red Hat') and optional 'cpes' list." + ), + ) def to_representation(self, instance): data = super().to_representation(instance) @@ -207,6 +231,21 @@ def validate(self, data): {"checksum_type": _(ALLOWED_PUBLISH_CHECKSUM_ERROR_MSG)} ) + if "osv_config" in data: + osv_config = data.pop("osv_config") + from pulp_rpm.app.vuln_report import ( + OsvConfigSerializer, # noqa: avoid circular at module load + ) + + current_labels = dict(self.instance.pulp_labels) if self.instance else {} + labels = {**current_labels, **data.get("pulp_labels", {})} + if osv_config is None: + labels.pop(LABEL_OSV_CONFIG, None) + else: + OsvConfigSerializer(data={"config": osv_config}).is_valid(raise_exception=True) + labels[LABEL_OSV_CONFIG] = urllib.parse.quote(json.dumps(osv_config)) + data["pulp_labels"] = labels + validated_data = super().validate(data) return validated_data @@ -226,6 +265,7 @@ class Meta: "repo_config", "compression_type", "layout", + "osv_config", ) model = RpmRepository diff --git a/pulp_rpm/app/viewsets/repository.py b/pulp_rpm/app/viewsets/repository.py index 23c83c00c..74ecf4275 100644 --- a/pulp_rpm/app/viewsets/repository.py +++ b/pulp_rpm/app/viewsets/repository.py @@ -332,7 +332,7 @@ class RpmRepositoryVersionViewSet(RepositoryVersionViewSet): description="Dispatch a task to scan all packages in this repository version for known CVEs via osv.dev.", responses={202: AsyncOperationResponseSerializer}, ) - @action(detail=True, methods=["post"]) + @action(detail=True, methods=["post"], serializer_class=None) def vulnerability_report(self, request, repository_pk, **kwargs): repository_version = self.get_object() repo = repository_version.repository diff --git a/pulp_rpm/app/vuln_report.py b/pulp_rpm/app/vuln_report.py index 3604cc9d9..9802489e7 100644 --- a/pulp_rpm/app/vuln_report.py +++ b/pulp_rpm/app/vuln_report.py @@ -1,6 +1,8 @@ from __future__ import annotations +import json import re +import urllib.parse from collections.abc import AsyncGenerator, Generator from gettext import gettext as _ from typing import Any, TypedDict, cast @@ -67,10 +69,6 @@ class OsvConfigSerializer(serializers.Serializer): config = serializers.JSONField() def validate_config(self, value: Any) -> list[OsvEcosystem]: - if value is None: - raise DRFValidationError( - {LABEL_OSV_CONFIG: _("Required label '%s' is missing.") % LABEL_OSV_CONFIG} - ) if not isinstance(value, list) or not value: raise serializers.ValidationError(_("Must be a non-empty JSON list.")) @@ -101,10 +99,21 @@ def parse_osv_labels(labels: dict[str, str]) -> list[OsvEcosystem]: """Parse and validate the osv.rpm.config label. Returns a list of OsvEcosystem entries. Raises DRFValidationError if absent or malformed. + The label value must be a JSON list, optionally URL-encoded to satisfy Pulp's label + constraint that forbids commas and parentheses in label values. """ - raw = labels.get(LABEL_OSV_CONFIG, None) - s = OsvConfigSerializer(data={"config": raw}) - s.is_valid(raise_exception=True) + raw = labels.get(LABEL_OSV_CONFIG) + if raw is None: + raise DRFValidationError( + {LABEL_OSV_CONFIG: _("Required label '%s' is missing.") % LABEL_OSV_CONFIG} + ) + try: + parsed = json.loads(urllib.parse.unquote(raw)) + except (json.JSONDecodeError, ValueError): + raise DRFValidationError({LABEL_OSV_CONFIG: _("Must be a valid JSON list.")}) + s = OsvConfigSerializer(data={"config": parsed}) + if not s.is_valid(): + raise DRFValidationError({LABEL_OSV_CONFIG: s.errors}) return s.validated_data["config"] @@ -137,9 +146,7 @@ async def generate_vuln_report_payloads( repository_version_pk: str, ) -> AsyncGenerator[VulnReportPayload, None]: """Generator of OSV query dicts for rpm.packages in a repository version.""" - repo_version: RepositoryVersion = await RepositoryVersion.objects.aget( - pk=repository_version_pk - ) + repo_version: RepositoryVersion = await RepositoryVersion.objects.aget(pk=repository_version_pk) repo: Any = await sync_to_async(lambda: repo_version.repository)() labels: dict[str, str] = await sync_to_async(lambda: dict(repo.pulp_labels))() ecosystems = parse_osv_labels(labels) diff --git a/pulp_rpm/tests/functional/api/test_vuln_report.py b/pulp_rpm/tests/functional/api/test_vuln_report.py index 008af6b10..c5b4701f2 100644 --- a/pulp_rpm/tests/functional/api/test_vuln_report.py +++ b/pulp_rpm/tests/functional/api/test_vuln_report.py @@ -1,7 +1,10 @@ """Functional tests for the vulnerability_report action on RpmRepositoryVersionViewSet.""" + +import json +import urllib.parse + import pytest -from pulpcore.client.pulp_rpm import RepositoryVersion from pulpcore.client.pulp_rpm.exceptions import ApiException from pulp_rpm.tests.functional.utils import MetaPackage, Nevra @@ -20,15 +23,20 @@ LABEL_OSV_CONFIG = "osv.rpm.config" +def _osv_label(config: list) -> str: + """Serialize an OSV config list as a URL-encoded JSON string safe for Pulp labels.""" + return urllib.parse.quote(json.dumps(config)) + + @pytest.fixture -def repo_with_kernel( +def pulp_repo_with_kernel( rpm_repository_factory, rpm_repository_api, rpm_package_api, monitor_task, tmp_path ): def _factory(labels): repo = rpm_repository_factory(pulp_labels=labels) - nevra = Nevra(name="kernel", epoch="0", version="3.10.0", release="123.el7", arch="x86_64") + nevra = Nevra(name="kernel", epoch=0, version="3.10.0", release="123.el7", arch="x86_64") rpm_path = tmp_path / f"{nevra.to_nvra()}.rpm" - MetaPackage(nevra=nevra).write_file(rpm_path) + MetaPackage(nevra=nevra).write_to(rpm_path) monitor_task(rpm_package_api.create(file=str(rpm_path), repository=repo.pulp_href).task) return rpm_repository_api.read(repo.pulp_href) @@ -39,28 +47,30 @@ def _factory(labels): "labels", [ pytest.param( - {LABEL_OSV_CONFIG: '[{"name": "Red Hat", "cpes": ["cpe:/o:redhat:enterprise_linux:7::workstation"]}]'}, + { + LABEL_OSV_CONFIG: _osv_label( + [{"name": "Red Hat", "cpes": ["cpe:/o:redhat:enterprise_linux:7::workstation"]}] + ) + }, id="redhat_with_cpe", ), pytest.param( - {LABEL_OSV_CONFIG: '[{"name": "Red Hat"}]'}, + {LABEL_OSV_CONFIG: _osv_label([{"name": "Red Hat"}])}, id="redhat_without_cpe", ), ], ) def test_vuln_report_redhat( labels, - repo_with_kernel, + pulp_repo_with_kernel, monitor_task, rpm_repository_versions_api, pulpcore_bindings, ): """Known RHSA IDs appear in the report for both CPE and non-CPE Red Hat configs.""" - repo = repo_with_kernel(labels) + repo = pulp_repo_with_kernel(labels) - resp = rpm_repository_versions_api.vulnerability_report( - repo.latest_version_href, RepositoryVersion() - ) + resp = rpm_repository_versions_api.vulnerability_report(repo.latest_version_href) monitor_task(resp.task) results = pulpcore_bindings.VulnReportApi.list(repo_versions=repo.latest_version_href).results @@ -105,9 +115,7 @@ def test_vuln_report_invalid_config( repo = rpm_repository_factory(pulp_labels=labels) with pytest.raises(ApiException) as exc: - rpm_repository_versions_api.vulnerability_report( - repo.latest_version_href, RepositoryVersion() - ) + rpm_repository_versions_api.vulnerability_report(repo.latest_version_href) assert exc.value.status == 400 assert LABEL_OSV_CONFIG in exc.value.body assert expected in exc.value.body diff --git a/pulp_rpm/tests/functional/utils.py b/pulp_rpm/tests/functional/utils.py index dbabdbd17..e47850f55 100644 --- a/pulp_rpm/tests/functional/utils.py +++ b/pulp_rpm/tests/functional/utils.py @@ -114,7 +114,7 @@ def get_metadata_content_helper(base_url, repomd_elem, meta_type): class Nevra(NamedTuple): name: str - epoch: str + epoch: int version: str release: str arch: str @@ -140,7 +140,7 @@ class MetaPackage: def generate_nevra(cls, n: int) -> Nevra: return Nevra( name=f"pkg{n}-{SALT[:8]}", - epoch="0", + epoch=0, version=f"{n}.0", release=f"{n}", arch="noarch", @@ -150,17 +150,13 @@ def generate_nevra(cls, n: int) -> Nevra: def generate_digest(cls, n: int) -> str: return hashlib.sha256(f"digest-{SALT}-{n}".encode()).hexdigest() - def write_file(self, path: Path) -> None: - built = ( - rpm_rs.PackageBuilder( - self.nevra.name, self.nevra.version, "GPLv2", self.nevra.arch - ) - .release(self.nevra.release) - .epoch(self.nevra.epoch) - .build() - ) - built.write_file(path) - self.digest = hashlib.sha256(path.read_bytes()).hexdigest() + def write_to(self, path: Path) -> None: + built = rpm_rs.PackageBuilder(self.nevra.name, self.nevra.version, "GPLv2", self.nevra.arch) + built.release(self.nevra.release) + built.epoch(self.nevra.epoch) + pkg = built.build() + built_path = Path(pkg.write_to(path)) + self.digest = hashlib.sha256(built_path.read_bytes()).hexdigest() def normalized_location(pkg: MetaPackage, prefix: bool = True) -> MetaPackage: @@ -203,14 +199,14 @@ def from_repository_metadata(self, url: str) -> PackageList: def from_pulp_repoversion(self, repoversion_href: str) -> PackageList: """Inspect what is in a Pulp repository. - Build from a Pulp repository version using the packages API. + Build from a Pulp repository version using the packages API. """ response = self._rpm_package_api.list(repository_version=repoversion_href, limit=1000) packages = [ MetaPackage( nevra=Nevra( name=pkg.name, - epoch=pkg.epoch, + epoch=int(pkg.epoch), version=pkg.version, release=pkg.release, arch=pkg.arch, @@ -231,7 +227,7 @@ def _from_path(path: str) -> PackageList: MetaPackage( nevra=Nevra( name=p.name, - epoch=p.epoch, + epoch=int(p.epoch), version=p.version, release=p.release, arch=p.arch, @@ -278,7 +274,7 @@ def build( cr_pkg = cr.Package() cr_pkg.name = pkg.nevra.name cr_pkg.arch = pkg.nevra.arch - cr_pkg.epoch = pkg.nevra.epoch + cr_pkg.epoch = str(pkg.nevra.epoch) cr_pkg.version = pkg.nevra.version cr_pkg.release = pkg.nevra.release cr_pkg.pkgId = pkg.digest @@ -301,7 +297,7 @@ def build( else: rpm_path = repo_dir / pkg.location rpm_path.parent.mkdir(parents=True, exist_ok=True) - pkg.write_file(rpm_path) + pkg.write_to(rpm_path) cr_pkg = cr.package_from_rpm(str(rpm_path)) cr_pkg.location_href = pkg.location cr_packages.append(cr_pkg) diff --git a/pulp_rpm/tests/unit/test_serializers.py b/pulp_rpm/tests/unit/test_serializers.py new file mode 100644 index 000000000..1ec79d09c --- /dev/null +++ b/pulp_rpm/tests/unit/test_serializers.py @@ -0,0 +1,24 @@ +import json +import urllib.parse +from unittest.mock import MagicMock + +import pytest + +from pulp_rpm.app.serializers.repository import OsvConfigField + +_CONFIG = {"ecosystem": "rpm", "repo": "myrepo"} + + +@pytest.mark.parametrize( + "labels,expected", + [ + ({}, None), + ({"osv.rpm.config": urllib.parse.quote(json.dumps(_CONFIG))}, _CONFIG), + ({"osv.rpm.config": json.dumps(_CONFIG)}, _CONFIG), + ({"osv.rpm.config": "not-json"}, None), + ], +) +def test_osv_config_field_get_attribute(labels, expected): + instance = MagicMock() + instance.pulp_labels = labels + assert OsvConfigField().get_attribute(instance) == expected From aea61425f7cb84840a02c2e4362e9753ed067ac1 Mon Sep 17 00:00:00 2001 From: Pedro Brochado Date: Wed, 3 Jun 2026 09:36:44 -0300 Subject: [PATCH 5/7] Revert "Improve repository builder test util" This reverts commit ab29285106f727376a4e71ce3cdc779be9a9fe65. --- .../tests/functional/api/test_vuln_report.py | 13 +-- pulp_rpm/tests/functional/utils.py | 85 +++++++------------ 2 files changed, 39 insertions(+), 59 deletions(-) diff --git a/pulp_rpm/tests/functional/api/test_vuln_report.py b/pulp_rpm/tests/functional/api/test_vuln_report.py index c5b4701f2..80765b87f 100644 --- a/pulp_rpm/tests/functional/api/test_vuln_report.py +++ b/pulp_rpm/tests/functional/api/test_vuln_report.py @@ -4,11 +4,10 @@ import urllib.parse import pytest +import rpm_rs from pulpcore.client.pulp_rpm.exceptions import ApiException -from pulp_rpm.tests.functional.utils import MetaPackage, Nevra - EXPECTED_RHSA_IDS = [ "RHSA-2014:0678", "RHSA-2014:0786", @@ -34,9 +33,13 @@ def pulp_repo_with_kernel( ): def _factory(labels): repo = rpm_repository_factory(pulp_labels=labels) - nevra = Nevra(name="kernel", epoch=0, version="3.10.0", release="123.el7", arch="x86_64") - rpm_path = tmp_path / f"{nevra.to_nvra()}.rpm" - MetaPackage(nevra=nevra).write_to(rpm_path) + pkg = ( + rpm_rs.PackageBuilder("kernel", "3.10.0", "GPLv2", "x86_64") + .release("123.el7") + .build() + ) + rpm_path = tmp_path / "kernel-3.10.0-123.el7.x86_64.rpm" + pkg.write_file(rpm_path) monitor_task(rpm_package_api.create(file=str(rpm_path), repository=repo.pulp_href).task) return rpm_repository_api.read(repo.pulp_href) diff --git a/pulp_rpm/tests/functional/utils.py b/pulp_rpm/tests/functional/utils.py index e47850f55..4c85d4f61 100644 --- a/pulp_rpm/tests/functional/utils.py +++ b/pulp_rpm/tests/functional/utils.py @@ -15,7 +15,6 @@ import createrepo_c as cr import pyzstd import requests -import rpm_rs from pulp_rpm.tests.functional.constants import ( LEGACY_SIGNING_KEY, @@ -131,10 +130,9 @@ class MetaPackage: """Simplified package representation.""" nevra: Nevra - digest: str = "" - time_build: int = 0 - location: str = "" - metadata_only: bool = False + digest: str + time_build: int + location: str @classmethod def generate_nevra(cls, n: int) -> Nevra: @@ -150,14 +148,6 @@ def generate_nevra(cls, n: int) -> Nevra: def generate_digest(cls, n: int) -> str: return hashlib.sha256(f"digest-{SALT}-{n}".encode()).hexdigest() - def write_to(self, path: Path) -> None: - built = rpm_rs.PackageBuilder(self.nevra.name, self.nevra.version, "GPLv2", self.nevra.arch) - built.release(self.nevra.release) - built.epoch(self.nevra.epoch) - pkg = built.build() - built_path = Path(pkg.write_to(path)) - self.digest = hashlib.sha256(built_path.read_bytes()).hexdigest() - def normalized_location(pkg: MetaPackage, prefix: bool = True) -> MetaPackage: """Return a copy of pkg with location set to the canonical NVRA filename.""" @@ -173,23 +163,20 @@ class RemoteRepository: class PackageList(list[MetaPackage]): - """Parsed package list from an RPM repository.""" + """Parsed package list from an RPM repository. Behaves as a list of MetaPackage.""" def filter(self, name: str) -> "PackageList": return PackageList(p for p in self if p.nevra.name == name) class PackageListFetcher: - """Inspect packages from a generic RPM repo (through metadata) or from Pulp repositories.""" + """Builds PackageList instances; wires in the packages API for Pulp-side queries.""" def __init__(self, rpm_package_api): self._rpm_package_api = rpm_package_api def from_repository_metadata(self, url: str) -> PackageList: - """Inspect what is in a RPM published metadata (Pulp's or not). - - Build from a file:// or http(s):// URL pointing to an RPM repository. - """ + """Build from a file:// or http(s):// URL pointing to an RPM repository.""" if url.startswith("file://"): repodata = Path(url[len("file://") :]) / "repodata" primary = next(repodata.glob("*primary.xml*")) @@ -197,10 +184,7 @@ def from_repository_metadata(self, url: str) -> PackageList: return self._from_http_url(url) def from_pulp_repoversion(self, repoversion_href: str) -> PackageList: - """Inspect what is in a Pulp repository. - - Build from a Pulp repository version using the packages API. - """ + """Build from a Pulp repository version using the packages API.""" response = self._rpm_package_api.list(repository_version=repoversion_href, limit=1000) packages = [ MetaPackage( @@ -256,7 +240,7 @@ def _from_http_url(base_url: str) -> PackageList: class RepositoryBuilder: - """Builds a fixture RPM repository from MetaPackage declarative entries.""" + """Builds local RPM repositories from MetaPackage entries using createrepo_c.""" def __init__(self, tmp_path: Path): self._tmp_path = tmp_path @@ -270,36 +254,29 @@ def build( cr_packages = [] for pkg in packages: - if pkg.metadata_only: - cr_pkg = cr.Package() - cr_pkg.name = pkg.nevra.name - cr_pkg.arch = pkg.nevra.arch - cr_pkg.epoch = str(pkg.nevra.epoch) - cr_pkg.version = pkg.nevra.version - cr_pkg.release = pkg.nevra.release - cr_pkg.pkgId = pkg.digest - cr_pkg.checksum_type = "sha256" - cr_pkg.location_href = pkg.location - cr_pkg.summary = f"Headless package {pkg.nevra.name}" - cr_pkg.description = "" - cr_pkg.size_package = 0 - cr_pkg.size_installed = 0 - cr_pkg.size_archive = 0 - cr_pkg.time_file = 0 - cr_pkg.time_build = pkg.time_build - cr_pkg.rpm_header_start = 0 - cr_pkg.rpm_header_end = 0 - cr_pkg.rpm_license = "" - cr_pkg.rpm_vendor = "" - cr_pkg.rpm_group = "" - cr_pkg.rpm_buildhost = "" - cr_pkg.rpm_sourcerpm = "" - else: - rpm_path = repo_dir / pkg.location - rpm_path.parent.mkdir(parents=True, exist_ok=True) - pkg.write_to(rpm_path) - cr_pkg = cr.package_from_rpm(str(rpm_path)) - cr_pkg.location_href = pkg.location + cr_pkg = cr.Package() + cr_pkg.name = pkg.nevra.name + cr_pkg.arch = pkg.nevra.arch + cr_pkg.epoch = pkg.nevra.epoch + cr_pkg.version = pkg.nevra.version + cr_pkg.release = pkg.nevra.release + cr_pkg.pkgId = pkg.digest + cr_pkg.checksum_type = "sha256" + cr_pkg.location_href = pkg.location + cr_pkg.summary = f"Headless package {pkg.nevra.name}" + cr_pkg.description = "" + cr_pkg.size_package = 0 + cr_pkg.size_installed = 0 + cr_pkg.size_archive = 0 + cr_pkg.time_file = 0 + cr_pkg.time_build = pkg.time_build + cr_pkg.rpm_header_start = 0 + cr_pkg.rpm_header_end = 0 + cr_pkg.rpm_license = "" + cr_pkg.rpm_vendor = "" + cr_pkg.rpm_group = "" + cr_pkg.rpm_buildhost = "" + cr_pkg.rpm_sourcerpm = "" cr_packages.append(cr_pkg) with cr.RepositoryWriter(str(repo_dir), compression=cr.NO_COMPRESSION) as writer: From 402a439a7c63bc6031483337f001b8983a24aeee Mon Sep 17 00:00:00 2001 From: Pedro Brochado Date: Wed, 3 Jun 2026 09:39:25 -0300 Subject: [PATCH 6/7] lint --- pulp_rpm/tests/functional/api/test_vuln_report.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pulp_rpm/tests/functional/api/test_vuln_report.py b/pulp_rpm/tests/functional/api/test_vuln_report.py index 80765b87f..9c41bacc2 100644 --- a/pulp_rpm/tests/functional/api/test_vuln_report.py +++ b/pulp_rpm/tests/functional/api/test_vuln_report.py @@ -34,9 +34,7 @@ def pulp_repo_with_kernel( def _factory(labels): repo = rpm_repository_factory(pulp_labels=labels) pkg = ( - rpm_rs.PackageBuilder("kernel", "3.10.0", "GPLv2", "x86_64") - .release("123.el7") - .build() + rpm_rs.PackageBuilder("kernel", "3.10.0", "GPLv2", "x86_64").release("123.el7").build() ) rpm_path = tmp_path / "kernel-3.10.0-123.el7.x86_64.rpm" pkg.write_file(rpm_path) From 5ad1c894581affe1cdd2a2a5f8e02e163e6740e7 Mon Sep 17 00:00:00 2001 From: Pedro Brochado Date: Wed, 3 Jun 2026 16:35:06 -0300 Subject: [PATCH 7/7] wip --- pulp_rpm/app/serializers/package.py | 1 - pulp_rpm/app/vuln_report.py | 6 +- pulp_rpm/pytest_plugin.py | 11 +++- .../tests/functional/api/test_vuln_report.py | 66 ++++++++----------- pulp_rpm/tests/functional/conftest.py | 15 +++++ pulp_rpm/tests/functional/utils.py | 12 +++- 6 files changed, 65 insertions(+), 46 deletions(-) diff --git a/pulp_rpm/app/serializers/package.py b/pulp_rpm/app/serializers/package.py index d3de956d8..ba3b52021 100644 --- a/pulp_rpm/app/serializers/package.py +++ b/pulp_rpm/app/serializers/package.py @@ -420,7 +420,6 @@ class Meta(PackageSerializer.Meta): ref_name = "RPMPackageUploadSerializer" def validate(self, data): - uploaded_file = data.get("file") artifact = data.get("artifact") upload = data.get("upload") diff --git a/pulp_rpm/app/vuln_report.py b/pulp_rpm/app/vuln_report.py index 9802489e7..39902ff06 100644 --- a/pulp_rpm/app/vuln_report.py +++ b/pulp_rpm/app/vuln_report.py @@ -55,7 +55,11 @@ def validate_name(self, value: str) -> str: class RedHatEcosystemConfigSerializer(EcosystemConfigSerializer): - cpes = serializers.ListField(child=serializers.CharField(), required=False, default=list) + cpes = serializers.ListField( + child=serializers.CharField(), + required=True, + help_text=_("CPEs are required for Red Hat to scope the OSV query to a specific product."), + ) _ECOSYSTEM_SERIALIZERS: dict[str, type[EcosystemConfigSerializer]] = { diff --git a/pulp_rpm/pytest_plugin.py b/pulp_rpm/pytest_plugin.py index c2a8007de..4ad3278ef 100644 --- a/pulp_rpm/pytest_plugin.py +++ b/pulp_rpm/pytest_plugin.py @@ -69,16 +69,21 @@ def rpm_repository_api(rpm_client): @pytest.fixture(scope="class") -def rpm_repository_factory(rpm_repository_api, gen_object_with_cleanup): +def rpm_repository_factory(rpm_repository_api, rpm_package_api, gen_object_with_cleanup): """A factory to generate an RPM Repository with auto-deletion after the test run.""" - def _rpm_repository_factory(pulp_domain=None, **body): + def _rpm_repository_factory(pulp_domain=None, upload_packages=None, monitor_task=None, **body): data = {"name": str(uuid.uuid4())} data.update(body) kwargs = {} if pulp_domain: kwargs["pulp_domain"] = pulp_domain - return gen_object_with_cleanup(rpm_repository_api, data, **kwargs) + repo = gen_object_with_cleanup(rpm_repository_api, data, **kwargs) + if upload_packages: + for path in upload_packages: + monitor_task(rpm_package_api.create(file=str(path), repository=repo.pulp_href).task) + repo = rpm_repository_api.read(repo.pulp_href) + return repo return _rpm_repository_factory diff --git a/pulp_rpm/tests/functional/api/test_vuln_report.py b/pulp_rpm/tests/functional/api/test_vuln_report.py index 9c41bacc2..d1a3cbd13 100644 --- a/pulp_rpm/tests/functional/api/test_vuln_report.py +++ b/pulp_rpm/tests/functional/api/test_vuln_report.py @@ -4,10 +4,11 @@ import urllib.parse import pytest -import rpm_rs from pulpcore.client.pulp_rpm.exceptions import ApiException +from pulp_rpm.tests.functional.utils import Nevra + EXPECTED_RHSA_IDS = [ "RHSA-2014:0678", "RHSA-2014:0786", @@ -27,57 +28,39 @@ def _osv_label(config: list) -> str: return urllib.parse.quote(json.dumps(config)) -@pytest.fixture -def pulp_repo_with_kernel( - rpm_repository_factory, rpm_repository_api, rpm_package_api, monitor_task, tmp_path -): - def _factory(labels): - repo = rpm_repository_factory(pulp_labels=labels) - pkg = ( - rpm_rs.PackageBuilder("kernel", "3.10.0", "GPLv2", "x86_64").release("123.el7").build() - ) - rpm_path = tmp_path / "kernel-3.10.0-123.el7.x86_64.rpm" - pkg.write_file(rpm_path) - monitor_task(rpm_package_api.create(file=str(rpm_path), repository=repo.pulp_href).task) - return rpm_repository_api.read(repo.pulp_href) - - return _factory +REDHAT_CPE_LABELS = { + LABEL_OSV_CONFIG: _osv_label( + [{"name": "Red Hat", "cpes": ["cpe:/o:redhat:enterprise_linux:7::workstation"]}] + ) +} -@pytest.mark.parametrize( - "labels", - [ - pytest.param( - { - LABEL_OSV_CONFIG: _osv_label( - [{"name": "Red Hat", "cpes": ["cpe:/o:redhat:enterprise_linux:7::workstation"]}] - ) - }, - id="redhat_with_cpe", - ), - pytest.param( - {LABEL_OSV_CONFIG: _osv_label([{"name": "Red Hat"}])}, - id="redhat_without_cpe", - ), - ], -) def test_vuln_report_redhat( - labels, - pulp_repo_with_kernel, + rpm_repository_factory, + rpm_create_package, monitor_task, rpm_repository_versions_api, pulpcore_bindings, ): - """Known RHSA IDs appear in the report for both CPE and non-CPE Red Hat configs.""" - repo = pulp_repo_with_kernel(labels) + """Known RHSA IDs appear in the report for a Red Hat config with CPEs.""" + kernel_nevra = Nevra(name="kernel", epoch=0, version="3.10.0", release="123.el7", arch="x86_64") + repo = rpm_repository_factory( + pulp_labels=REDHAT_CPE_LABELS, + upload_packages=[rpm_create_package(kernel_nevra)], + monitor_task=monitor_task, + ) resp = rpm_repository_versions_api.vulnerability_report(repo.latest_version_href) monitor_task(resp.task) - results = pulpcore_bindings.VulnReportApi.list(repo_versions=repo.latest_version_href).results - ids = {vuln["id"] for report in results for vuln in report.vulns} + vulns_list = pulpcore_bindings.VulnReportApi.list() + assert len(vulns_list.results) > 0 + ids = {vuln["id"] for report in vulns_list.results for vuln in report.vulns} assert set(EXPECTED_RHSA_IDS).issubset(ids) + repo_version = rpm_repository_versions_api.read(repo.latest_version_href) + assert repo_version.vuln_report is not None + @pytest.mark.parametrize( "labels,expected", @@ -107,6 +90,11 @@ def test_vuln_report_redhat( "valid JSON", id="whitespace_only", ), + pytest.param( + {LABEL_OSV_CONFIG: _osv_label([{"name": "Red Hat"}])}, + "cpes", + id="redhat_missing_cpes", + ), ], ) def test_vuln_report_invalid_config( diff --git a/pulp_rpm/tests/functional/conftest.py b/pulp_rpm/tests/functional/conftest.py index 5e7b74f44..4f60e5cff 100644 --- a/pulp_rpm/tests/functional/conftest.py +++ b/pulp_rpm/tests/functional/conftest.py @@ -3,6 +3,7 @@ import subprocess import uuid from dataclasses import dataclass +from pathlib import Path from tempfile import NamedTemporaryFile import gnupg @@ -36,8 +37,10 @@ RPM_SIGNED_URL, ) from pulp_rpm.tests.functional.utils import ( + Nevra, PackageListFetcher, RepositoryBuilder, + build_rpm, init_signed_repo_configuration, ) @@ -167,6 +170,18 @@ def _rpm_artifact_factory(url=RPM_SIGNED_URL, pulp_domain=None): return _rpm_artifact_factory +@pytest.fixture +def rpm_create_package(tmp_path): + """Return a factory that builds a minimal RPM file and returns its path.""" + + def _factory(nevra: Nevra) -> Path: + path = tmp_path / f"{nevra.to_nvra()}.rpm" + build_rpm(nevra, path) + return path + + return _factory + + @pytest.fixture def rpm_package_factory( gen_object_with_cleanup, diff --git a/pulp_rpm/tests/functional/utils.py b/pulp_rpm/tests/functional/utils.py index 4c85d4f61..ff946629a 100644 --- a/pulp_rpm/tests/functional/utils.py +++ b/pulp_rpm/tests/functional/utils.py @@ -15,6 +15,7 @@ import createrepo_c as cr import pyzstd import requests +import rpm_rs from pulp_rpm.tests.functional.constants import ( LEGACY_SIGNING_KEY, @@ -149,6 +150,13 @@ def generate_digest(cls, n: int) -> str: return hashlib.sha256(f"digest-{SALT}-{n}".encode()).hexdigest() +def build_rpm(nevra: Nevra, path: Path) -> None: + """Build a minimal RPM file at path using rpm_rs.""" + builder = rpm_rs.PackageBuilder(nevra.name, nevra.version, "GPLv2", nevra.arch) + builder.release(nevra.release) + builder.build().write_file(path) + + def normalized_location(pkg: MetaPackage, prefix: bool = True) -> MetaPackage: """Return a copy of pkg with location set to the canonical NVRA filename.""" filename = f"{pkg.nevra.to_nvra()}.rpm" @@ -240,7 +248,7 @@ def _from_http_url(base_url: str) -> PackageList: class RepositoryBuilder: - """Builds local RPM repositories from MetaPackage entries using createrepo_c.""" + """Builds a pseudo-remote RPM repository.""" def __init__(self, tmp_path: Path): self._tmp_path = tmp_path @@ -257,7 +265,7 @@ def build( cr_pkg = cr.Package() cr_pkg.name = pkg.nevra.name cr_pkg.arch = pkg.nevra.arch - cr_pkg.epoch = pkg.nevra.epoch + cr_pkg.epoch = str(pkg.nevra.epoch) cr_pkg.version = pkg.nevra.version cr_pkg.release = pkg.nevra.release cr_pkg.pkgId = pkg.digest