Skip to content

Commit a3a7fce

Browse files
committed
Add the vulnerability report
closes: #6773
1 parent 17446d4 commit a3a7fce

21 files changed

Lines changed: 457 additions & 1 deletion

CHANGES/6773.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added the vulnerability report data model.

docs/admin/reference/tech-preview.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ The following features are currently being released as part of tech preview:
55
- [Support for Open Telemetry](site:pulpcore/docs/admin/learn/architecture/#telemetry-support)
66
- Upstream replicas
77
- Domains - Multi-Tenancy
8-
- [Checkpoint](site:pulpcore/docs/user/guides/checkpoint/)
8+
- [Checkpoint](site:pulpcore/docs/user/guides/checkpoint/)
9+
- [Vulnerability Report](site:pulpcore/docs/dev/learn/other/vulnerability-report/)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Adding vulnerability report for plugins
2+
3+
4+
!!! warning
5+
This feature is provided as a tech preview and could change in backwards incompatible
6+
ways in the future.
7+
8+
9+
Pulp provides a way to store known vulnerabilities from OSV for `content units` within a specified `RepositoryVersion`.
10+
Each plugin will need to implement a function to construct the [package payload](https://google.github.io/osv.dev/post-v1-query/#parameters)
11+
that will be used to query osv.dev database.
12+
13+
!!! note
14+
As of now, querying by osv.dev `commit` is not supported (use `package` instead).
15+
16+
The first step in writing a vulnerability report for a Pulp `content unit` is to identify the
17+
package [`ecosystem`](https://google.github.io/osv.dev/post-v1-query/#parameters) by checking
18+
[https://ossf.github.io/osv-schema/#defined-ecosystems](https://ossf.github.io/osv-schema/#defined-ecosystems).
19+
20+
The next step is to create an async function at the top level of the module (so it can be
21+
loaded in pulpcore) that will be run as a Pulp task. This async function should return a generator
22+
object with a dictionary containing the `osv_data` (created through `build_osv_data` function in the following sample),
23+
and also the `Content` and `RepositoryVersion` objects.
24+
25+
Here is an example of a function with the above steps:
26+
27+
```python
28+
async def get_content_from_repo_version(repo_version_pk: str):
29+
repo_version = await sync_to_async(RepositoryVersion.objects.get)(pk=repo_version_pk)
30+
content_units = await sync_to_async(list)(repo_version.content.all())
31+
32+
for content_unit in content_units:
33+
content = await sync_to_async(content_unit.cast)()
34+
content_name = await sync_to_async(lambda: content.name)()
35+
content_version = await sync_to_async(lambda: content.version)()
36+
ecosystem = "PyPI"
37+
repo_content_osv_data = _build_osv_data(content_name, ecosystem, content_version)
38+
repo_content_osv_data["repo_version"] = repo_version
39+
repo_content_osv_data["content"] = content
40+
yield repo_content_osv_data
41+
42+
def _build_osv_data(name, ecosystem, version=None):
43+
osv_data = {"package": {"name": name, "ecosystem": ecosystem}}
44+
if version:
45+
osv_data["version"] = version
46+
return osv_data
47+
```
48+
49+
50+
Now that we have the async generator function, we need to create a `ViewSet` to dispatch a task with it:
51+
52+
!!! note
53+
In the following sample, we are not defining the permissions to access the endpoint.
54+
Plugin writters should define them according to each plugin needs.
55+
56+
```python
57+
from rest_framework.views import APIView
58+
59+
from pulpcore.plugin.models import VulnerabilityReport
60+
from pulpcore.plugin.serializers import AsyncOperationResponseSerializer, VulnerabilityReportSerializer
61+
from pulpcore.plugin.tasking import check_content, dispatch
62+
from pulpcore.plugin.viewsets import OperationPostponedResponse
63+
64+
from my_plugin.app.serializers import MyPluginVulnerabilityReportSerializer
65+
from my_plugin.app.utils import get_content_from_repo_version
66+
67+
class MyPluginVulnerabilityReport(APIView):
68+
69+
queryset = VulnerabilityReport.objects.all()
70+
serializer_class = VulnerabilityReportSerializer
71+
72+
@extend_schema(
73+
request=MyPluginVulnerabilityReportSerializer,
74+
description="Trigger a task to generate the package vulnerability report",
75+
summary="Generate vulnerability report",
76+
responses={202: AsyncOperationResponseSerializer},
77+
)
78+
def create(self, request):
79+
serializer = MyPluginVulnerabilityReportSerializer(data=request.data, context={"request": request})
80+
serializer.is_valid(raise_exception=True)
81+
repo_version = serializer.validated_data["repo_version"]
82+
83+
# we need to pass the function as string because dispatch() args only accepts JSON serializable content
84+
func = f"{get_content_from_repo_version.__module__}.{get_content_from_repo_version.__name__}"
85+
86+
task = dispatch(
87+
check_content,
88+
shared_resources=[repo_version.repository],
89+
args = [func, [repo_version.pk]],
90+
)
91+
return OperationPostponedResponse(task, request)
92+
```
93+
94+
Here is a sample for the `MyPluginVulnerabilityReportSerializer` where we serialize the
95+
`RepositoryVersion` string into a `RepositoryVersionRelatedField` object:
96+
97+
```python
98+
from rest_framework import serializers
99+
from pulpcore.plugin.serializers import ValidateFieldsMixin, RepositoryVersionRelatedField
100+
101+
class MyPluginVulnerabilityReportSerializer(serializers.Serializer, ValidateFieldsMixin):
102+
103+
repo_version = RepositoryVersionRelatedField(
104+
required=True,
105+
allow_null=False,
106+
help_text=_("RepositoryVersion HREF with the packages to be checked."),
107+
)
108+
```
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 4.2.19 on 2025-08-04 11:08
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import django_lifecycle.mixins
6+
import pulpcore.app.models.base
7+
import pulpcore.app.util
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
('core', '0133_repositoryversion_content_ids'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='VulnerabilityReport',
19+
fields=[
20+
('pulp_created', models.DateTimeField(auto_now_add=True)),
21+
('pulp_last_updated', models.DateTimeField(auto_now=True, null=True)),
22+
('pulp_type', models.TextField(db_index=True, default=None)),
23+
('pulp_id', models.UUIDField(default=pulpcore.app.models.base.pulp_uuid, editable=False, unique=True)),
24+
('content', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='core.content')),
25+
('vulns', models.JSONField()),
26+
('pulp_domain', models.ForeignKey(default=pulpcore.app.util.get_domain_pk, on_delete=django.db.models.deletion.CASCADE, to='core.domain')),
27+
('repo_versions', models.ManyToManyField(blank=True, to='core.repositoryversion')),
28+
],
29+
options={
30+
'default_related_name': '%(app_label)s_%(model_name)s',
31+
},
32+
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
33+
),
34+
]

pulpcore/app/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@
8787
UploadChunk,
8888
)
8989

90+
from .vulnerability_report import VulnerabilityReport
91+
9092
# Moved here to avoid a circular import with Task
9193
from .progress import GroupProgressReport, ProgressReport
9294

@@ -170,4 +172,5 @@
170172
"OpenPGPSignature",
171173
"OpenPGPUserAttribute",
172174
"OpenPGPUserID",
175+
"VulnerabilityReport",
173176
]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.db import models
2+
3+
from pulpcore.app.models.base import MasterModel, pulp_uuid
4+
from pulpcore.app.util import get_domain_pk
5+
6+
7+
class VulnerabilityReport(MasterModel):
8+
"""
9+
Model used in vulnerability report.
10+
"""
11+
12+
TYPE = "vuln_report"
13+
pulp_id = models.UUIDField(default=pulp_uuid, editable=False, unique=True)
14+
content = models.OneToOneField(
15+
"core.Content",
16+
on_delete=models.CASCADE,
17+
primary_key=True,
18+
)
19+
vulns = models.JSONField()
20+
pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.CASCADE)
21+
repo_versions = models.ManyToManyField("core.RepositoryVersion", blank=True)
22+
23+
class Meta:
24+
default_related_name = "%(app_label)s_%(model_name)s"

pulpcore/app/serializers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
UserSerializer,
122122
)
123123
from .replica import UpstreamPulpSerializer
124+
from .vulnerability_report import VulnerabilityReportSerializer
124125
from .openpgp import (
125126
OpenPGPDistributionSerializer,
126127
OpenPGPKeyringSerializer,

pulpcore/app/serializers/content.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ class NoArtifactContentSerializer(base.ModelSerializer):
2727
view_name_pattern=r"repositories(-.*/.*)-detail",
2828
queryset=models.Repository.objects.all(),
2929
)
30+
core_vulnerabilityreport = DetailRelatedField(
31+
read_only=True,
32+
view_name="vuln_report-detail",
33+
)
3034

3135
def get_artifacts(self, validated_data):
3236
"""
@@ -116,6 +120,7 @@ class Meta:
116120
fields = base.ModelSerializer.Meta.fields + (
117121
"repository",
118122
"pulp_labels",
123+
"core_vulnerabilityreport",
119124
)
120125

121126

pulpcore/app/serializers/repository.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,12 @@ class RepositoryVersionSerializer(ModelSerializer, NestedHyperlinkedModelSeriali
491491
read_only=True,
492492
)
493493

494+
core_vulnerabilityreport = DetailRelatedField(
495+
many=True,
496+
read_only=True,
497+
view_name="vuln_report-detail",
498+
)
499+
494500
class Meta:
495501
model = models.RepositoryVersion
496502
fields = ModelSerializer.Meta.fields + (
@@ -499,6 +505,7 @@ class Meta:
499505
"repository",
500506
"base_version",
501507
"content_summary",
508+
"core_vulnerabilityreport",
502509
)
503510

504511

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from rest_framework import serializers
2+
3+
from pulpcore.app.models import VulnerabilityReport
4+
from pulpcore.app.serializers import (
5+
DetailRelatedField,
6+
IdentityField,
7+
ModelSerializer,
8+
RepositoryVersionRelatedField,
9+
)
10+
11+
12+
class VulnerabilityReportSerializer(ModelSerializer):
13+
"""
14+
A serializer for the VulnerabilityReport Model.
15+
"""
16+
17+
vulns = serializers.JSONField()
18+
pulp_href = IdentityField(view_name="vuln_report-detail")
19+
content = DetailRelatedField(
20+
read_only=True,
21+
view_name_pattern=r"content(-.*/.*)-detail",
22+
)
23+
repo_versions = RepositoryVersionRelatedField(many=True, required=False)
24+
25+
class Meta:
26+
model = VulnerabilityReport
27+
fields = ModelSerializer.Meta.fields + ("vulns", "repo_versions", "content")

0 commit comments

Comments
 (0)