Skip to content

Commit 099575e

Browse files
committed
Implements the vulnerability report
closes: #1012
1 parent 3af3ef2 commit 099575e

File tree

7 files changed

+225
-2
lines changed

7 files changed

+225
-2
lines changed

CHANGES/1012.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added the new /scan endpoint to the RepositoryVersion viewset to generate vulnerability reports.

docs/user/guides/_SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
* [Sync from Remote Repositories](sync.md)
33
* [Upload and Manage Content](upload.md)
44
* [Publish and Host Python Content](publish.md)
5+
* [Vulnerability Report](vulnerability_report.md)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Vulnerability Report
2+
3+
Pulp Python provides vulnerability scanning capabilities to help you identify known security
4+
vulnerabilities in your Python packages. This feature integrates with the [Open Source Vulnerabilities (OSV)](https://osv.dev/)
5+
database to scan Pulp `RepositoryVersions` for vulnerable packages.
6+
7+
## Prerequisites
8+
9+
Before generating the vulnerability report, ensure that:
10+
11+
1. You have a Python repository with [synced or uploaded content](site:pulp_python/docs/user/guides/sync/)
12+
2. Pulp has connectivity to the [OSV API](https://api.osv.dev/v1/query)
13+
14+
## Generating a vulnerability report
15+
16+
To scan a `RepositoryVersion` for vulnerabilities, you need to make a POST request to the `/scan`
17+
endpoint of it:
18+
19+
```bash
20+
REPO_VERSION_HREF=$(pulp python repository show --name my-repo | jq -r '.latest_version_href')
21+
curl -XPOST -u <user>:<password> ${BASE_ADDR}${REPO_VERSION_HREF}scan/
22+
```
23+
24+
## Understanding Scan Results
25+
26+
After a scan completes, vulnerability information is available in two places:
27+
28+
### 1. Repository Version Level
29+
30+
The `RepositoryVersion` includes a `vuln_report` field that references a vulnerability report
31+
containing all vulnerabilities found in that version:
32+
33+
```bash
34+
pulp python repository version show --repository my-repo
35+
```
36+
37+
The response includes:
38+
39+
```json
40+
{
41+
"pulp_href": "/pulp/api/v3/repositories/python/python/.../versions/1/",
42+
"number": 1,
43+
...
44+
"vuln_report": "/pulp/api/v3/vuln-reports/..."
45+
}
46+
```
47+
48+
### 2. Content Level
49+
50+
Individual Python package content units also include vulnerability report references:
51+
52+
```bash
53+
pulp python content list
54+
```
55+
56+
Each package in the response includes:
57+
58+
```json
59+
{
60+
"pulp_href": "/pulp/api/v3/content/python/packages/.../",
61+
"name": "Django",
62+
...
63+
"vuln_report": "/pulp/api/v3/vuln-reports/...",
64+
...
65+
}
66+
```
67+
68+
### Viewing Vulnerability Details
69+
70+
To view the actual vulnerability data, retrieve the vulnerability report:
71+
72+
```bash
73+
# Get vulnerability report details
74+
pulp show --href ${VULN_REPORT_HREF}
75+
```
76+
77+
The report contains detailed information about each vulnerability, including:
78+
79+
- **CVE identifiers**: Common Vulnerabilities and Exposures identifiers
80+
- **Affected versions**: Which package versions are vulnerable
81+
- **Fixed versions**: Which versions contain fixes
82+
- **References**: Links to advisories and patches
83+
- **Repository and Content**: Pulp `RepositoryVersion` and `Content` impacted
84+
85+
## Example Workflow
86+
87+
Here's a complete example of scanning a repository for vulnerabilities:
88+
89+
```bash
90+
# 1. Create a repository
91+
pulp python repository create --name security-scan-repo
92+
93+
# 2. Create a remote pointing to PyPI
94+
pulp python remote create \
95+
--name pypi-remote \
96+
--url https://pypi.org/ \
97+
--includes '["django==5.2.1"]'
98+
99+
# 3. Sync the repository
100+
pulp python repository sync \
101+
--name security-scan-repo \
102+
--remote pypi-remote
103+
104+
# 4. Get the latest version href
105+
REPO=$(pulp python repository show --name security-scan-repo)
106+
VERSION_HREF=$(echo $REPO | jq -r '.latest_version_href')
107+
108+
# 5. Scan for vulnerabilities
109+
curl -XPOST -u <user>:<password> ${BASE_ADDR}${VERSION_HREF}scan/
110+
111+
# 6. View the vulnerability report
112+
VULN_REPORT=$(pulp python repository version show --repository security-scan-repo | jq -r '.vuln_report')
113+
pulp show --href $VULN_REPORT
114+
```

pulp_python/app/viewsets.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from asgiref.sync import sync_to_async
12
from bandersnatch.configuration import BandersnatchConfig
23
from django.db import transaction
34
from drf_spectacular.utils import extend_schema
@@ -13,7 +14,8 @@
1314
AsyncOperationResponseSerializer,
1415
RepositorySyncURLSerializer,
1516
)
16-
from pulpcore.plugin.tasking import dispatch
17+
from pulpcore.plugin.sync import sync_to_async_iterable
18+
from pulpcore.plugin.tasking import check_content, dispatch
1719

1820
from pulp_python.app import models as python_models
1921
from pulp_python.app import serializers as python_serializers
@@ -206,9 +208,34 @@ class PythonRepositoryVersionViewSet(core_viewsets.RepositoryVersionViewSet):
206208
"has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository",
207209
],
208210
},
211+
{
212+
"action": ["scan"],
213+
"principal": "authenticated",
214+
"effect": "allow",
215+
"condition": [
216+
"has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository",
217+
],
218+
},
209219
],
210220
}
211221

222+
@extend_schema(
223+
summary="Generate vulnerability report", responses={202: AsyncOperationResponseSerializer}
224+
)
225+
@action(detail=True, methods=["post"], serializer_class=None)
226+
def scan(self, request, repository_pk, **kwargs):
227+
"""
228+
Scan a repository version for vulnerabilities.
229+
"""
230+
repository_version = self.get_object()
231+
func = f"{get_repo_version_content.__module__}.{get_repo_version_content.__name__}"
232+
task = dispatch(
233+
check_content,
234+
shared_resources=[repository_version.repository],
235+
args=[func, [repository_version.pk]],
236+
)
237+
return core_viewsets.OperationPostponedResponse(task, request)
238+
212239

213240
class PythonDistributionViewSet(core_viewsets.DistributionViewSet, core_viewsets.RolesMixin):
214241
"""
@@ -624,3 +651,27 @@ def create(self, request):
624651
kwargs={"repository_version_pk": str(repository_version.pk)},
625652
)
626653
return core_viewsets.OperationPostponedResponse(result, request)
654+
655+
656+
async def get_repo_version_content(repo_version_pk: str):
657+
"""
658+
Retrieve Python package content from a repository version for vulnerability scanning.
659+
"""
660+
repo_version = await sync_to_async(RepositoryVersion.objects.get)(pk=repo_version_pk)
661+
content_units = python_models.PythonPackageContent.objects.filter(pk__in=repo_version.content)
662+
ecosystem = "PyPI"
663+
async for content in sync_to_async_iterable(content_units):
664+
repo_content_osv_data = _build_osv_data(content.name, ecosystem, content.version)
665+
repo_content_osv_data["repo_version"] = repo_version
666+
repo_content_osv_data["content"] = content
667+
yield repo_content_osv_data
668+
669+
670+
def _build_osv_data(name, ecosystem, version=None):
671+
"""
672+
Build an OSV data structure for vulnerability queries.
673+
"""
674+
osv_data = {"package": {"name": name, "ecosystem": ecosystem}}
675+
if version:
676+
osv_data["version"] = version
677+
return osv_data
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import pytest
2+
3+
from pulp_python.tests.functional.constants import (
4+
PYPI_URL,
5+
VULNERABILITY_REPORT_TEST_PACKAGE_NAME,
6+
VULNERABILITY_REPORT_TEST_PACKAGES,
7+
)
8+
9+
10+
@pytest.mark.parallel
11+
def test_vulnerability_report(
12+
pulpcore_bindings, python_bindings, python_repo, python_remote_factory, monitor_task
13+
):
14+
15+
# Sync the test repository.
16+
remote = python_remote_factory(url=PYPI_URL, includes=VULNERABILITY_REPORT_TEST_PACKAGES)
17+
sync_data = dict(remote=remote.pulp_href)
18+
response = python_bindings.RepositoriesPythonApi.sync(python_repo.pulp_href, sync_data)
19+
monitor_task(response.task)
20+
21+
# get repo latest version
22+
repo = python_bindings.RepositoriesPythonApi.read(python_repo.pulp_href)
23+
latest_version_href = repo.latest_version_href
24+
25+
# scan
26+
response = python_bindings.RepositoriesPythonVersionsApi.scan(
27+
python_python_repository_version_href=latest_version_href
28+
)
29+
monitor_task(response.task)
30+
31+
# checks
32+
vulns_list = pulpcore_bindings.VulnReportApi.list()
33+
assert len(vulns_list.results) > 0
34+
for results in vulns_list.results:
35+
assert len(results.vulns) > 0
36+
for vuln in results.vulns:
37+
assert VULNERABILITY_REPORT_TEST_PACKAGE_NAME.lower() in (
38+
affected["package"]["name"] for affected in vuln["affected"]
39+
)
40+
41+
repo_version = python_bindings.RepositoriesPythonVersionsApi.read(
42+
python_python_repository_version_href=latest_version_href
43+
)
44+
assert repo_version.vuln_report is not None
45+
46+
python_packages = python_bindings.ContentPackagesApi.list(
47+
name=VULNERABILITY_REPORT_TEST_PACKAGE_NAME,
48+
repository_version=latest_version_href
49+
)
50+
for content in python_packages.results:
51+
assert content.vuln_report is not None

pulp_python/tests/functional/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,8 @@
345345
"releases": {"0.1": SHELF_0DOT1_RELEASE},
346346
"urls": SHELF_0DOT1_RELEASE,
347347
}
348+
349+
VULNERABILITY_REPORT_TEST_PACKAGE_NAME = "Django"
350+
VULNERABILITY_REPORT_TEST_PACKAGES = [
351+
"django==5.2.1",
352+
]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ classifiers=[
2626
]
2727
requires-python = ">=3.11"
2828
dependencies = [
29-
"pulpcore>=3.81.0,<3.100",
29+
"pulpcore>=3.85.0,<3.100",
3030
"pkginfo>=1.12.0,<1.13.0",
3131
"bandersnatch>=6.6.0,<6.7",
3232
"pypi-simple>=1.5.0,<2.0",

0 commit comments

Comments
 (0)