Skip to content

Commit be0622e

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

File tree

9 files changed

+224
-2
lines changed

9 files changed

+224
-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: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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 pass the name of the repository and
17+
optionally the version:
18+
19+
```bash
20+
pulp vulnerability-report create --repository my-repo --version 1
21+
```
22+
23+
## Understanding Scan Results
24+
25+
After a scan completes, vulnerability information is available in two places:
26+
27+
### 1. Repository Version Level
28+
29+
The `RepositoryVersion` includes a `vuln_report` field that references a vulnerability report
30+
containing all vulnerabilities found in that version:
31+
32+
```bash
33+
pulp python repository version show --repository my-repo
34+
```
35+
36+
The response includes:
37+
38+
```json
39+
{
40+
"pulp_href": "/pulp/api/v3/repositories/python/python/.../versions/1/",
41+
"number": 1,
42+
...
43+
"vuln_report": "/pulp/api/v3/vuln-reports/..."
44+
}
45+
```
46+
47+
### 2. Content Level
48+
49+
Individual Python package content units also include vulnerability report references:
50+
51+
```bash
52+
pulp python content list
53+
```
54+
55+
Each package in the response includes:
56+
57+
```json
58+
{
59+
"pulp_href": "/pulp/api/v3/content/python/packages/.../",
60+
"name": "Django",
61+
...
62+
"vuln_report": "/pulp/api/v3/vuln-reports/...",
63+
...
64+
}
65+
```
66+
67+
### Viewing Vulnerability Details
68+
69+
To view the actual vulnerability data, retrieve the vulnerability report:
70+
71+
```bash
72+
# Get vulnerability report details
73+
pulp vulnerability-report show --href ${VULN_REPORT_HREF}
74+
```
75+
76+
The report contains detailed information about each vulnerability, including:
77+
78+
- **CVE identifiers**: Common Vulnerabilities and Exposures identifiers
79+
- **Affected versions**: Which package versions are vulnerable
80+
- **Fixed versions**: Which versions contain fixes
81+
- **References**: Links to advisories and patches
82+
- **Repository and Content**: Pulp `RepositoryVersion` and `Content` impacted
83+
84+
## Example Workflow
85+
86+
Here's a complete example of scanning a repository for vulnerabilities:
87+
88+
```bash
89+
# 1. Create a repository
90+
pulp python repository create --name security-scan-repo
91+
92+
# 2. Create a remote pointing to PyPI
93+
pulp python remote create \
94+
--name pypi-remote \
95+
--url https://pypi.org/ \
96+
--includes '["django==5.2.1"]'
97+
98+
# 3. Sync the repository
99+
pulp python repository sync \
100+
--name security-scan-repo \
101+
--remote pypi-remote
102+
103+
# 4. Scan for vulnerabilities
104+
pulp vulnerability-report create --repository security-scan-repo
105+
106+
# 5. View the vulnerability report
107+
VULN_REPORT=$(pulp python repository version show --repository security-scan-repo | jq -r '.vuln_report')
108+
pulp vulnerability-report show --href $VULN_REPORT
109+
```

pulp_python/app/tasks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
from .repair import repair # noqa:F401
77
from .sync import sync # noqa:F401
88
from .upload import upload, upload_group # noqa:F401
9+
from .vulnerability_report import get_repo_version_content # noqa:F401
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from pulpcore.plugin.models import RepositoryVersion
2+
from pulpcore.plugin.sync import sync_to_async_iterable
3+
4+
from pulp_python.app.models import PythonPackageContent
5+
6+
7+
async def get_repo_version_content(repo_version_pk: str):
8+
"""
9+
Retrieve Python package content from a repository version for vulnerability scanning.
10+
"""
11+
repo_version = await RepositoryVersion.objects.aget(pk=repo_version_pk)
12+
content_units = PythonPackageContent.objects.filter(pk__in=repo_version.content)
13+
ecosystem = "PyPI"
14+
async for content in sync_to_async_iterable(content_units):
15+
repo_content_osv_data = _build_osv_data(content.name, ecosystem, content.version)
16+
repo_content_osv_data["repo_version"] = repo_version
17+
repo_content_osv_data["content"] = content
18+
yield repo_content_osv_data
19+
20+
21+
def _build_osv_data(name, ecosystem, version=None):
22+
"""
23+
Build an OSV data structure for vulnerability queries.
24+
"""
25+
osv_data = {"package": {"name": name, "ecosystem": ecosystem}}
26+
if version:
27+
osv_data["version"] = version
28+
return osv_data

pulp_python/app/viewsets.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
AsyncOperationResponseSerializer,
1414
RepositorySyncURLSerializer,
1515
)
16-
from pulpcore.plugin.tasking import dispatch
16+
from pulpcore.plugin.tasking import check_content, dispatch
1717

1818
from pulp_python.app import models as python_models
1919
from pulp_python.app import serializers as python_serializers
@@ -206,9 +206,36 @@ class PythonRepositoryVersionViewSet(core_viewsets.RepositoryVersionViewSet):
206206
"has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository",
207207
],
208208
},
209+
{
210+
"action": ["scan"],
211+
"principal": "authenticated",
212+
"effect": "allow",
213+
"condition": [
214+
"has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository",
215+
],
216+
},
209217
],
210218
}
211219

220+
@extend_schema(
221+
summary="Generate vulnerability report", responses={202: AsyncOperationResponseSerializer}
222+
)
223+
@action(detail=True, methods=["post"], serializer_class=None)
224+
def scan(self, request, repository_pk, **kwargs):
225+
"""
226+
Scan a repository version for vulnerabilities.
227+
"""
228+
repository_version = self.get_object()
229+
func = (
230+
f"{tasks.get_repo_version_content.__module__}.{tasks.get_repo_version_content.__name__}"
231+
)
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
"""
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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, repository_version=latest_version_href
48+
)
49+
for content in python_packages.results:
50+
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)