Skip to content

Commit f10f251

Browse files
authored
feat: ability to assign and manage vulnerabilities on products (#439)
Signed-off-by: tdruez <[email protected]>
1 parent 3039028 commit f10f251

File tree

9 files changed

+179
-36
lines changed

9 files changed

+179
-36
lines changed

dje/tests/testfiles/test_dataset_pp_only.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"uuid": "565737ed-ab23-46eb-bbcf-185da2da50dc",
7575
"created_date": "2011-08-24T09:20:01Z",
7676
"last_modified_date": "2011-08-24T09:20:01Z",
77+
"risk_score": null,
7778
"keywords": [
7879
"Framework"
7980
],
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Generated by Django 5.2.8 on 2025-12-17 12:00
2+
3+
import django.db.models.deletion
4+
import dje.models
5+
import uuid
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('dje', '0012_alter_dataspaceconfiguration_sourcehut_token'),
13+
('product_portfolio', '0014_scancodeproject_infer_download_urls'),
14+
('vulnerabilities', '0005_vulnerabilityanalysis_is_reachable_and_more'),
15+
]
16+
17+
operations = [
18+
migrations.AddField(
19+
model_name='product',
20+
name='risk_score',
21+
field=models.DecimalField(blank=True, decimal_places=1, help_text='Risk score between 0.0 and 10.0, where higher values indicate greater vulnerability risk for the package.', max_digits=3, null=True),
22+
),
23+
migrations.CreateModel(
24+
name='ProductAffectedByVulnerability',
25+
fields=[
26+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
27+
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')),
28+
('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')),
29+
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='product_portfolio.product')),
30+
('vulnerability', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vulnerabilities.vulnerability')),
31+
],
32+
options={
33+
'unique_together': {('dataspace', 'uuid'), ('product', 'vulnerability')},
34+
},
35+
bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model),
36+
),
37+
migrations.AddField(
38+
model_name='product',
39+
name='affected_by_vulnerabilities',
40+
field=models.ManyToManyField(help_text='Vulnerabilities directly affecting this product.', related_name='affected_%(class)ss', through='product_portfolio.ProductAffectedByVulnerability', to='vulnerabilities.vulnerability'),
41+
),
42+
]

product_portfolio/models.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
from dje.validators import validate_url_segment
5151
from dje.validators import validate_version
5252
from vulnerabilities.fetch import fetch_for_packages
53+
from vulnerabilities.models import AffectedByVulnerabilityMixin
54+
from vulnerabilities.models import AffectedByVulnerabilityRelationship
5355

5456
RELATION_LICENSE_EXPRESSION_HELP_TEXT = _(
5557
"The License Expression assigned to a DejaCode Product Package or Product "
@@ -204,7 +206,13 @@ def get_related_secured_queryset(self, user):
204206
BaseProductMixin = component_mixin_factory("product")
205207

206208

207-
class Product(BaseProductMixin, FieldChangesMixin, KeywordsMixin, DataspacedModel):
209+
class Product(
210+
BaseProductMixin,
211+
FieldChangesMixin,
212+
KeywordsMixin,
213+
AffectedByVulnerabilityMixin,
214+
DataspacedModel,
215+
):
208216
license_expression = models.CharField(
209217
max_length=1024,
210218
blank=True,
@@ -278,6 +286,13 @@ class Product(BaseProductMixin, FieldChangesMixin, KeywordsMixin, DataspacedMode
278286
through="ProductPackage",
279287
)
280288

289+
affected_by_vulnerabilities = models.ManyToManyField(
290+
to="vulnerabilities.Vulnerability",
291+
through="ProductAffectedByVulnerability",
292+
related_name="affected_%(class)ss",
293+
help_text=_("Vulnerabilities directly affecting this product."),
294+
)
295+
281296
objects = ProductSecuredManager()
282297

283298
# WARNING: Bypass the security system implemented in ProductSecuredManager.
@@ -634,6 +649,16 @@ def get_vulnerability_qs(self, prefetch_related_packages=False, risk_threshold=N
634649
return vulnerability_qs
635650

636651

652+
class ProductAffectedByVulnerability(AffectedByVulnerabilityRelationship):
653+
product = models.ForeignKey(
654+
to="product_portfolio.Product",
655+
on_delete=models.CASCADE,
656+
)
657+
658+
class Meta:
659+
unique_together = (("product", "vulnerability"), ("dataspace", "uuid"))
660+
661+
637662
class ProductRelationStatus(BaseStatusMixin, DataspacedModel):
638663
class Meta(BaseStatusMixin.Meta):
639664
verbose_name_plural = _("product relation status")
@@ -731,7 +756,7 @@ def update_license_unknown(self):
731756
product_package.update_license_unknown()
732757

733758
def annotate_weighted_risk_score(self):
734-
"""Annotate the Queeryset with the weighted_risk_score computed value."""
759+
"""Annotate the Queryset with the weighted_risk_score computed value."""
735760
purpose = ProductItemPurpose.objects.filter(productpackage=OuterRef("pk"))
736761
package = Package.objects.filter(productpackages=OuterRef("pk"))
737762

product_portfolio/templates/product_portfolio/tables/product_list_table.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
<a href="{% inject_preserved_filters product.get_absolute_url %}#activity" class="r-link"><span class="badge text-bg-request">R</span></a>
3030
</li>
3131
{% endif %}
32-
{% if product.is_vulnerable %}
32+
{% if product.has_vulnerable_packages %}
3333
<li class="list-inline-item">
3434
{% include 'component_catalog/includes/vulnerability_icon_link.html' with url=product.get_absolute_url only %}
3535
</li>

product_portfolio/tests/test_models.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,11 @@ def test_product_model_all_packages(self):
155155
def test_product_model_get_vulnerable_packages(self):
156156
self.assertEqual(0, self.product1.get_vulnerable_packages().count())
157157

158-
package1 = make_package(self.dataspace, is_vulnerable=True, risk_score=5.0)
158+
package1 = make_package(self.dataspace)
159+
vulnerability1 = make_vulnerability(self.dataspace, risk_score=5.0)
160+
package1.add_affected_by(vulnerability1)
159161
make_product_package(self.product1, package1)
162+
160163
self.assertEqual(1, self.product1.get_vulnerable_packages().count())
161164
self.assertEqual(0, self.product1.get_vulnerable_packages(risk_threshold=6.0).count())
162165
self.assertEqual(1, self.product1.get_vulnerable_packages(risk_threshold=4.0).count())
@@ -600,6 +603,27 @@ def test_product_model_improve_packages_from_purldb(self, mock_update_from_purld
600603
pp1.refresh_from_db()
601604
self.assertEqual("apache-2.0", pp1.license_expression)
602605

606+
def test_product_model_affected_by_vulnerabilities(self):
607+
vulnerability1 = make_vulnerability(self.dataspace, risk_score=1.0)
608+
vulnerability2 = make_vulnerability(self.dataspace, risk_score=10.0)
609+
vulnerability3 = make_vulnerability(self.dataspace, risk_score=5.0)
610+
611+
vulnerability1.add_affected(self.product1)
612+
affected_by = self.product1.affected_by_vulnerabilities.all()
613+
self.assertQuerySetEqual([vulnerability1], affected_by)
614+
self.product1.refresh_from_db()
615+
self.assertEqual(1.0, self.product1.risk_score)
616+
617+
vulnerability2.add_affected(self.product1)
618+
affected_by = self.product1.affected_by_vulnerabilities.order_by("id")
619+
self.assertQuerySetEqual([vulnerability1, vulnerability2], affected_by)
620+
self.product1.refresh_from_db()
621+
self.assertEqual(10.0, self.product1.risk_score)
622+
623+
vulnerability3.add_affected(self.product1)
624+
self.product1.refresh_from_db()
625+
self.assertEqual(10.0, self.product1.risk_score)
626+
603627
def test_product_model_get_vulnerability_qs(self):
604628
package1 = make_package(self.dataspace)
605629
package2 = make_package(self.dataspace)

product_portfolio/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ def get_queryset(self):
208208
)
209209
.annotate(
210210
productinventoryitem_count=Count("productinventoryitem", distinct=True),
211-
is_vulnerable=Exists(vulnerable_productpackage_qs),
211+
has_vulnerable_packages=Exists(vulnerable_productpackage_qs),
212212
)
213213
.order_by(
214214
"name",

vulnerabilities/fetch.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def create_or_update_vulnerability(
135135
if updated_fields:
136136
results["updated"] += 1
137137

138-
vulnerability.add_affected_packages(affected_packages)
138+
vulnerability.add_affected(affected_packages)
139139
return vulnerability
140140

141141

vulnerabilities/models.py

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class Vulnerability(HistoryDateFieldsMixin, DataspacedModel):
6060
A software vulnerability with a unique identifier and alternate aliases.
6161
6262
Adapted from the VulnerableCode models at
63-
https://github.com/nexB/vulnerablecode/blob/main/vulnerabilities/models.py#L164
63+
https://github.com/nexB/vulnerablecode/blob/main/vulnerabilities/models.py
6464
6565
Note that this model implements the HistoryDateFieldsMixin but not the
6666
HistoryUserFieldsMixin as the Vulnerability records are usually created
@@ -172,31 +172,12 @@ def cve(self):
172172
return alias
173173

174174
def add_affected(self, instances):
175-
"""
176-
Assign the ``instances`` (Package or Component) as affected to this
177-
vulnerability.
178-
"""
179-
from component_catalog.models import Component
180-
from component_catalog.models import Package
181-
182-
if not isinstance(instances, list):
175+
"""Assign the ``instances`` (Package or Product) as affected by this vulnerability."""
176+
if not isinstance(instances, (list, tuple, models.QuerySet)):
183177
instances = [instances]
184178

185179
for instance in instances:
186-
if isinstance(instance, Package):
187-
self.add_affected_packages([instance])
188-
if isinstance(instance, Component):
189-
self.add_affected_components([instance])
190-
191-
def add_affected_packages(self, packages):
192-
"""Assign the ``packages`` as affected to this vulnerability."""
193-
through_defaults = {"dataspace_id": self.dataspace_id}
194-
self.affected_packages.add(*packages, through_defaults=through_defaults)
195-
196-
def add_affected_components(self, components):
197-
"""Assign the ``components`` as affected to this vulnerability."""
198-
through_defaults = {"dataspace_id": self.dataspace_id}
199-
self.affected_components.add(*components, through_defaults=through_defaults)
180+
instance.add_affected_by(vulnerability=self)
200181

201182
@classmethod
202183
def create_from_data(cls, dataspace, data, validate=False, affecting=None):
@@ -420,6 +401,21 @@ class Meta:
420401
def is_vulnerable(self):
421402
return self.affected_by_vulnerabilities.exists()
422403

404+
def update_risk_score(self):
405+
"""Calculate and save the maximum risk score from all affected vulnerabilities."""
406+
qs = self.affected_by_vulnerabilities.aggregate(models.Max("risk_score"))
407+
max_score = qs["risk_score__max"]
408+
409+
self.risk_score = max_score
410+
self.save(update_fields=["risk_score"])
411+
return self.risk_score
412+
413+
def add_affected_by(self, vulnerability):
414+
"""Add ``vulnerability`` as affecting this instance."""
415+
through_defaults = {"dataspace_id": self.dataspace_id}
416+
self.affected_by_vulnerabilities.add(vulnerability, through_defaults=through_defaults)
417+
self.update_risk_score()
418+
423419
def get_entry_for_package(self, vulnerablecode):
424420
if not self.package_url:
425421
return
@@ -487,7 +483,7 @@ def create_vulnerabilities(self, vulnerabilities_data):
487483
through_defaults = {"dataspace_id": self.dataspace_id}
488484
self.affected_by_vulnerabilities.add(*vulnerabilities, through_defaults=through_defaults)
489485

490-
self.update(risk_score=vulnerability_data["risk_score"])
486+
self.update_risk_score()
491487
if isinstance(self, Package):
492488
self.productpackages.update_weighted_risk_score()
493489

vulnerabilities/tests/test_models.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,74 @@ def test_vulnerability_mixin_create_vulnerabilities(self):
8282
response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
8383
response_json = json.loads(response_file.read_text())
8484
vulnerabilities_data = response_json["results"][0]["affected_by_vulnerabilities"]
85+
vulnerabilities_data.append({"vulnerability_id": "VCID-0002", "risk_score": 5.0})
8586

8687
package1 = make_package(self.dataspace, package_url="pkg:pypi/[email protected]")
8788
product1 = make_product(self.dataspace, inventory=[package1])
8889
package1.create_vulnerabilities(vulnerabilities_data)
8990

90-
self.assertEqual(1, Vulnerability.objects.scope(self.dataspace).count())
91-
self.assertEqual(1, package1.affected_by_vulnerabilities.count())
92-
vulnerability = package1.affected_by_vulnerabilities.get()
93-
self.assertEqual("VCID-j3au-usaz-aaag", vulnerability.vulnerability_id)
94-
95-
self.assertEqual(8.4, package1.risk_score)
91+
self.assertEqual(2, Vulnerability.objects.scope(self.dataspace).count())
92+
self.assertEqual("8.4", str(package1.risk_score))
9693
self.assertEqual("8.4", str(product1.productpackages.get().weighted_risk_score))
9794

95+
def test_vulnerability_mixin_update_risk_score(self):
96+
package1 = make_package(self.dataspace)
97+
98+
# Test with no vulnerabilities
99+
package1.update_risk_score()
100+
self.assertIsNone(package1.risk_score)
101+
102+
# Test with one vulnerability with risk score
103+
vulnerability1 = make_vulnerability(dataspace=self.dataspace, risk_score=7.5)
104+
vulnerability1.add_affected(package1)
105+
package1.update_risk_score()
106+
self.assertEqual("7.5", str(package1.risk_score))
107+
108+
# Test with multiple vulnerabilities, should use max
109+
vulnerability2 = make_vulnerability(dataspace=self.dataspace, risk_score=9.2)
110+
vulnerability2.add_affected(package1)
111+
package1.update_risk_score()
112+
self.assertEqual("9.2", str(package1.risk_score))
113+
114+
# Test with vulnerability with lower risk score, should keep max
115+
vulnerability3 = make_vulnerability(dataspace=self.dataspace, risk_score=3.1)
116+
vulnerability3.add_affected(package1)
117+
package1.update_risk_score()
118+
self.assertEqual("9.2", str(package1.risk_score))
119+
120+
# Test with all vulnerabilities having NULL risk scores
121+
package2 = make_package(self.dataspace)
122+
vulnerability4 = make_vulnerability(dataspace=self.dataspace, risk_score=None)
123+
vulnerability5 = make_vulnerability(dataspace=self.dataspace, risk_score=None)
124+
vulnerability4.add_affected(package2)
125+
vulnerability5.add_affected(package2)
126+
package2.update_risk_score()
127+
self.assertIsNone(package2.risk_score)
128+
129+
def test_vulnerability_mixin_add_affected_by(self):
130+
package1 = make_package(self.dataspace)
131+
132+
vulnerability1 = make_vulnerability(self.dataspace, risk_score=1.0)
133+
vulnerability2 = make_vulnerability(self.dataspace, risk_score=10.0)
134+
vulnerability3 = make_vulnerability(self.dataspace, risk_score=5.0)
135+
136+
package1.add_affected_by(vulnerability1)
137+
package1.refresh_from_db()
138+
self.assertEqual("1.0", str(package1.risk_score))
139+
140+
package1.add_affected_by(vulnerability2)
141+
package1.refresh_from_db()
142+
self.assertEqual("10.0", str(package1.risk_score))
143+
144+
package1.add_affected_by(vulnerability3)
145+
package1.refresh_from_db()
146+
self.assertEqual("10.0", str(package1.risk_score))
147+
148+
self.assertEqual(package1, vulnerability1.affected_packages.get())
149+
self.assertEqual(package1, vulnerability2.affected_packages.get())
150+
self.assertEqual(package1, vulnerability3.affected_packages.get())
151+
self.assertEqual(3, package1.affected_by_vulnerabilities.count())
152+
98153
def test_vulnerability_model_affected_packages_m2m(self):
99154
package1 = make_package(self.dataspace)
100155
vulnerability1 = make_vulnerability(dataspace=self.dataspace, affecting=package1)

0 commit comments

Comments
 (0)