Skip to content

Commit 6760696

Browse files
authored
Add vulnerabilities_risk_threshold fields #97 (#210)
Signed-off-by: tdruez <[email protected]>
1 parent 384047a commit 6760696

17 files changed

+183
-16
lines changed

CHANGELOG.rst

+5
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ Release notes
5353
new `data` dict.
5454
https://github.com/aboutcode-org/dejacode/issues/202
5555

56+
- Add the `vulnerabilities_risk_threshold` field to the Product and
57+
DataspaceConfiguration models.
58+
This threshold helps prioritize and control the level of attention to vulnerabilities.
59+
https://github.com/aboutcode-org/dejacode/issues/97
60+
5661
### Version 5.2.1
5762

5863
- Fix the models documentation navigation.

dje/admin.py

+1
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,7 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
10771077
"scancodeio_api_key",
10781078
"vulnerablecode_url",
10791079
"vulnerablecode_api_key",
1080+
"vulnerabilities_risk_threshold",
10801081
"purldb_url",
10811082
"purldb_api_key",
10821083
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.0.9 on 2024-12-13 08:05
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('dje', '0004_dataspace_vulnerabilities_updated_at'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='dataspaceconfiguration',
15+
name='vulnerabilities_risk_threshold',
16+
field=models.DecimalField(blank=True, decimal_places=1, help_text='Enter a risk value between 0.0 and 10.0. This threshold helps prioritize and control the level of attention to vulnerabilities.', max_digits=3, null=True),
17+
),
18+
]

dje/models.py

+24
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,19 @@ def get_configuration(self, field_name=None):
391391
return getattr(configuration, field_name, None)
392392
return configuration
393393

394+
def set_configuration(self, field_name, value):
395+
"""
396+
Set the `value` for `field_name` on the DataspaceConfiguration linked
397+
with this Dataspace instance.
398+
"""
399+
try:
400+
configuration = self.configuration
401+
except ObjectDoesNotExist:
402+
configuration = DataspaceConfiguration(dataspace=self)
403+
404+
setattr(configuration, field_name, value)
405+
configuration.save()
406+
394407
@property
395408
def has_configuration(self):
396409
"""Return True if an associated DataspaceConfiguration instance exists."""
@@ -473,6 +486,17 @@ class DataspaceConfiguration(models.Model):
473486
),
474487
)
475488

489+
vulnerabilities_risk_threshold = models.DecimalField(
490+
null=True,
491+
blank=True,
492+
max_digits=3,
493+
decimal_places=1,
494+
help_text=_(
495+
"Enter a risk value between 0.0 and 10.0. This threshold helps prioritize "
496+
"and control the level of attention to vulnerabilities."
497+
),
498+
)
499+
476500
purldb_url = models.URLField(
477501
_("PurlDB URL"),
478502
max_length=1024,

dje/templates/object_details_base.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ <h1 class="header-title text-break">
9191
</nav>
9292

9393
<div class="background-white">
94-
<div class="tab-content pt-4 px-0 container" style="height: 100%; min-height: 28.5em;">
94+
<div class="tab-content pt-3 px-0 container" style="height: 100%; min-height: 28.5em;">
9595
{% for tab_name, tab_context in tabsets.items %}
9696
{# IDs are prefixed with "tab_" to avoid autoscroll issue #}
9797
<div class="tab-pane{% if forloop.first %} show active{% endif %}" id="tab_{{ tab_name|slugify }}" role="tabpanel" aria-labelledby="tab_{{ tab_name|slugify }}-tab" tabindex="0">

dje/templates/tabs/pagination.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{% load humanize %}
22
<div class="row align-items-end">
3-
<div class="col mb-3">
3+
<div class="col mb-2">
44
<ul class="nav nav-pills">
55
<li class="nav-item">
66
<form id="tab-{{ tab_id }}-search-form" class="mt-md-0 me-sm-2">

dje/tests/test_models.py

+5
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ def test_dataspace_get_configuration(self):
212212

213213
self.assertIsNone(self.dataspace.get_configuration("non_available_field"))
214214

215+
def test_dataspace_set_configuration(self):
216+
self.dataspace.set_configuration("vulnerabilities_risk_threshold", 5.0)
217+
self.dataspace.refresh_from_db()
218+
self.assertEqual(5.0, self.dataspace.get_configuration("vulnerabilities_risk_threshold"))
219+
215220
def test_dataspace_has_configuration(self):
216221
self.assertFalse(self.dataspace.has_configuration)
217222
DataspaceConfiguration.objects.create(dataspace=self.dataspace)

dje/tests/testfiles/test_dataset_pp_only.json

+4-3
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@
2626
"vcs_url": "",
2727
"code_view_url": "",
2828
"bug_tracking_url": "",
29+
"md5": "",
30+
"sha1": "",
2931
"sha256": "",
3032
"sha512": "",
3133
"filename": "systemu-2.5.2.gem",
3234
"download_url": "https://s3.amazonaws.com/production.s3.rubygems.org/gems/systemu-2.5.2.gem",
33-
"sha1": "",
34-
"md5": "",
3535
"size": null,
3636
"release_date": null,
3737
"primary_language": "",
@@ -98,7 +98,8 @@
9898
"nexB",
9999
"addd9c5d-a5ec-48ec-a565-ddb81092f49d"
100100
],
101-
"contact": ""
101+
"contact": "",
102+
"vulnerabilities_risk_threshold": null
102103
}
103104
},
104105
{

product_portfolio/admin.py

+1
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ class ProductAdmin(
362362
"is_active",
363363
"configuration_status",
364364
"contact",
365+
"vulnerabilities_risk_threshold",
365366
"get_feature_datalist",
366367
)
367368
},

product_portfolio/api.py

+1
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ class Meta:
137137
"primary_language",
138138
"admin_notes",
139139
"notice_text",
140+
"vulnerabilities_risk_threshold",
140141
"created_date",
141142
"last_modified_date",
142143
)

product_portfolio/forms.py

+3
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ class Meta:
122122
"configuration_status",
123123
"contact",
124124
"keywords",
125+
"vulnerabilities_risk_threshold",
125126
]
126127
field_classes = {
127128
"owner": OwnerChoiceField,
@@ -170,6 +171,8 @@ def helper(self):
170171
HTML("<hr>"),
171172
Group("is_active", "configuration_status", "release_date"),
172173
HTML("<hr>"),
174+
Group("vulnerabilities_risk_threshold", HTML(""), HTML("")),
175+
HTML("<hr>"),
173176
Submit("submit", self.submit_label, css_class="btn-success"),
174177
self.save_as_new_submit,
175178
),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.0.9 on 2024-12-13 13:01
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('product_portfolio', '0008_productdependency_is_resolved_to_is_pinned'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='product',
15+
name='vulnerabilities_risk_threshold',
16+
field=models.DecimalField(blank=True, decimal_places=1, help_text='Enter a risk value between 0.0 and 10.0. This threshold helps prioritize and control the level of attention to vulnerabilities.', max_digits=3, null=True),
17+
),
18+
]

product_portfolio/models.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,17 @@ class Product(BaseProductMixin, FieldChangesMixin, KeywordsMixin, DataspacedMode
230230
),
231231
)
232232

233+
vulnerabilities_risk_threshold = models.DecimalField(
234+
null=True,
235+
blank=True,
236+
max_digits=3,
237+
decimal_places=1,
238+
help_text=_(
239+
"Enter a risk value between 0.0 and 10.0. This threshold helps prioritize "
240+
"and control the level of attention to vulnerabilities."
241+
),
242+
)
243+
233244
licenses = models.ManyToManyField(
234245
to="license_library.License",
235246
through="ProductAssignedLicense",
@@ -338,6 +349,16 @@ def all_packages(self):
338349
def vulnerability_count(self):
339350
return self.get_vulnerability_qs().count()
340351

352+
def get_vulnerabilities_risk_threshold(self):
353+
"""
354+
Return the local vulnerabilities_risk_threshold value when defined on the
355+
Product or look into the Dataspace configuration.
356+
"""
357+
risk_threshold = self.vulnerabilities_risk_threshold
358+
if not risk_threshold:
359+
risk_threshold = self.dataspace.get_configuration("vulnerabilities_risk_threshold")
360+
return risk_threshold
361+
341362
def get_merged_descendant_ids(self):
342363
"""
343364
Return a list of Component ids collected on the Product descendants:
@@ -527,7 +548,7 @@ def fetch_vulnerabilities(self):
527548
"""Fetch and update the vulnerabilties of all the Package of this Product."""
528549
return fetch_for_packages(self.all_packages, self.dataspace)
529550

530-
def get_vulnerability_qs(self, prefetch_related_packages=False):
551+
def get_vulnerability_qs(self, prefetch_related_packages=False, risk_threshold=None):
531552
"""Return a QuerySet of all Vulnerability instances related to this product"""
532553
from vulnerabilities.models import Vulnerability
533554
from vulnerabilities.models import VulnerabilityAnalysis
@@ -536,6 +557,9 @@ def get_vulnerability_qs(self, prefetch_related_packages=False):
536557
affected_packages__in=self.packages.all()
537558
).distinct()
538559

560+
if risk_threshold:
561+
vulnerability_qs = vulnerability_qs.filter(risk_score__gte=risk_threshold)
562+
539563
if prefetch_related_packages:
540564
package_qs = Package.objects.filter(product=self).only_rendering_fields()
541565
analysis_qs = VulnerabilityAnalysis.objects.filter(product=self).select_related(

product_portfolio/templates/product_portfolio/tabs/tab_vulnerabilities.html

+9-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
{% load as_icon from dje_tags %}
33

44
{% include 'tabs/pagination.html' %}
5+
6+
{% if risk_threshold %}
7+
<small class="d-inline-flex mb-3 px-2 py-1 fw-semibold text-warning-emphasis bg-warning-subtle border border-warning-subtle rounded-2">
8+
A risk threshold filter at "{{ risk_threshold }}" is currently applied.
9+
<a class="ms-1" href="?vulnerabilities-bypass_risk_threshold=Yes#vulnerabilities">Click here to see all vulnerabilities.</a>
10+
</small>
11+
{% endif %}
12+
513
<table class="table table-bordered table-md text-break">
614
{% include 'includes/object_list_table_header.html' with filter=filterset include_actions=True %}
715
<tbody>
@@ -76,7 +84,7 @@
7684
</td>
7785
<td class="text-center">
7886
{% if package.vulnerability_analysis.is_reachable %}
79-
<i class="fa-solid fa-circle-radiation text-danger fs-5" data-bs-toggle="tooltip" title="Vulnerability is reachable"></i>
87+
<i class="fa-solid fa-circle-radiation text-danger fs-6" data-bs-toggle="tooltip" title="Vulnerability is reachable"></i>
8088
{% elif package.vulnerability_analysis.is_reachable is False %}
8189
<i class="fa-solid fa-bug-slash" data-bs-toggle="tooltip" title="Vulnerability is NOT reachable"></i>
8290
{% endif %}

product_portfolio/tests/test_models.py

+21-2
Original file line numberDiff line numberDiff line change
@@ -508,8 +508,12 @@ def test_product_model_improve_packages_from_purldb(self, mock_update_from_purld
508508
def test_product_model_get_vulnerability_qs(self):
509509
package1 = make_package(self.dataspace)
510510
package2 = make_package(self.dataspace)
511-
vulnerability1 = make_vulnerability(self.dataspace, affecting=[package1, package2])
512-
vulnerability2 = make_vulnerability(self.dataspace, affecting=[package1, package2])
511+
vulnerability1 = make_vulnerability(
512+
self.dataspace, affecting=[package1, package2], risk_score=10.0
513+
)
514+
vulnerability2 = make_vulnerability(
515+
self.dataspace, affecting=[package1, package2], risk_score=1.0
516+
)
513517
make_product_package(self.product1, package=package1)
514518
make_product_package(self.product1, package=package2)
515519

@@ -519,6 +523,12 @@ def test_product_model_get_vulnerability_qs(self):
519523
self.assertIn(vulnerability1, queryset)
520524
self.assertIn(vulnerability2, queryset)
521525

526+
queryset = self.product1.get_vulnerability_qs(risk_threshold=5.0)
527+
# Makeing sure the distinct() is properly applied
528+
self.assertEqual(1, len(queryset))
529+
self.assertIn(vulnerability1, queryset)
530+
self.assertNotIn(vulnerability2, queryset)
531+
522532
def test_product_model_vulnerability_count_property(self):
523533
self.assertEqual(0, self.product1.vulnerability_count)
524534

@@ -534,6 +544,15 @@ def test_product_model_vulnerability_count_property(self):
534544
self.product1 = Product.unsecured_objects.get(pk=self.product1.pk)
535545
self.assertEqual(2, self.product1.vulnerability_count)
536546

547+
def test_product_model_get_vulnerabilities_risk_threshold(self):
548+
self.assertIsNone(self.product1.get_vulnerabilities_risk_threshold())
549+
550+
self.product1.dataspace.set_configuration("vulnerabilities_risk_threshold", 5.0)
551+
self.assertEqual(5.0, self.product1.get_vulnerabilities_risk_threshold())
552+
553+
self.product1.update(vulnerabilities_risk_threshold=10.0)
554+
self.assertEqual(10.0, self.product1.get_vulnerabilities_risk_threshold())
555+
537556
def test_productcomponent_model_license_expression_handle_assigned_licenses(self):
538557
p1 = ProductComponent.objects.create(
539558
product=self.product1, name="p1", dataspace=self.dataspace

product_portfolio/tests/test_views.py

+24-1
Original file line numberDiff line numberDiff line change
@@ -353,9 +353,32 @@ def test_product_portfolio_tab_vulnerability_view_queries(self):
353353
make_vulnerability_analysis(product_package2, vulnerability2)
354354

355355
url = product1.get_url("tab_vulnerabilities")
356-
with self.assertNumQueries(9):
356+
with self.assertNumQueries(10):
357357
self.client.get(url)
358358

359+
def test_product_portfolio_tab_vulnerability_risk_threshold(self):
360+
self.client.login(username="nexb_user", password="secret")
361+
362+
p1 = make_package(self.dataspace)
363+
vulnerability1 = make_vulnerability(self.dataspace, affecting=[p1], risk_score=1.0)
364+
vulnerability2 = make_vulnerability(self.dataspace, affecting=[p1], risk_score=5.0)
365+
product1 = make_product(self.dataspace)
366+
make_product_package(product1, package=p1)
367+
url = product1.get_url("tab_vulnerabilities")
368+
369+
response = self.client.get(url)
370+
self.assertContains(response, vulnerability1.vcid)
371+
self.assertContains(response, vulnerability2.vcid)
372+
self.assertContains(response, "2 results")
373+
self.assertNotContains(response, "A risk threshold filter at")
374+
375+
product1.update(vulnerabilities_risk_threshold=3.0)
376+
response = self.client.get(url)
377+
self.assertNotContains(response, vulnerability1.vcid)
378+
self.assertContains(response, vulnerability2.vcid)
379+
self.assertContains(response, "1 results")
380+
self.assertContains(response, 'A risk threshold filter at "3.0" is currently applied.')
381+
359382
def test_product_portfolio_tab_vulnerability_view_analysis_rendering(self):
360383
self.client.login(username="nexb_user", password="secret")
361384
# Each have a unique vulnerability, and p1 p2 are sharing a common one.

0 commit comments

Comments
 (0)