Skip to content

Commit

Permalink
Add vulnerabilities_risk_threshold fields #97 (#210)
Browse files Browse the repository at this point in the history
Signed-off-by: tdruez <[email protected]>
  • Loading branch information
tdruez authored Dec 17, 2024
1 parent 384047a commit 6760696
Show file tree
Hide file tree
Showing 17 changed files with 183 additions and 16 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ Release notes
new `data` dict.
https://github.com/aboutcode-org/dejacode/issues/202

- Add the `vulnerabilities_risk_threshold` field to the Product and
DataspaceConfiguration models.
This threshold helps prioritize and control the level of attention to vulnerabilities.
https://github.com/aboutcode-org/dejacode/issues/97

### Version 5.2.1

- Fix the models documentation navigation.
Expand Down
1 change: 1 addition & 0 deletions dje/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,7 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
"scancodeio_api_key",
"vulnerablecode_url",
"vulnerablecode_api_key",
"vulnerabilities_risk_threshold",
"purldb_url",
"purldb_api_key",
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.9 on 2024-12-13 08:05

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dje', '0004_dataspace_vulnerabilities_updated_at'),
]

operations = [
migrations.AddField(
model_name='dataspaceconfiguration',
name='vulnerabilities_risk_threshold',
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),
),
]
24 changes: 24 additions & 0 deletions dje/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,19 @@ def get_configuration(self, field_name=None):
return getattr(configuration, field_name, None)
return configuration

def set_configuration(self, field_name, value):
"""
Set the `value` for `field_name` on the DataspaceConfiguration linked
with this Dataspace instance.
"""
try:
configuration = self.configuration
except ObjectDoesNotExist:
configuration = DataspaceConfiguration(dataspace=self)

setattr(configuration, field_name, value)
configuration.save()

@property
def has_configuration(self):
"""Return True if an associated DataspaceConfiguration instance exists."""
Expand Down Expand Up @@ -473,6 +486,17 @@ class DataspaceConfiguration(models.Model):
),
)

vulnerabilities_risk_threshold = models.DecimalField(
null=True,
blank=True,
max_digits=3,
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."
),
)

purldb_url = models.URLField(
_("PurlDB URL"),
max_length=1024,
Expand Down
2 changes: 1 addition & 1 deletion dje/templates/object_details_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ <h1 class="header-title text-break">
</nav>

<div class="background-white">
<div class="tab-content pt-4 px-0 container" style="height: 100%; min-height: 28.5em;">
<div class="tab-content pt-3 px-0 container" style="height: 100%; min-height: 28.5em;">
{% for tab_name, tab_context in tabsets.items %}
{# IDs are prefixed with "tab_" to avoid autoscroll issue #}
<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">
Expand Down
2 changes: 1 addition & 1 deletion dje/templates/tabs/pagination.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{% load humanize %}
<div class="row align-items-end">
<div class="col mb-3">
<div class="col mb-2">
<ul class="nav nav-pills">
<li class="nav-item">
<form id="tab-{{ tab_id }}-search-form" class="mt-md-0 me-sm-2">
Expand Down
5 changes: 5 additions & 0 deletions dje/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@ def test_dataspace_get_configuration(self):

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

def test_dataspace_set_configuration(self):
self.dataspace.set_configuration("vulnerabilities_risk_threshold", 5.0)
self.dataspace.refresh_from_db()
self.assertEqual(5.0, self.dataspace.get_configuration("vulnerabilities_risk_threshold"))

def test_dataspace_has_configuration(self):
self.assertFalse(self.dataspace.has_configuration)
DataspaceConfiguration.objects.create(dataspace=self.dataspace)
Expand Down
7 changes: 4 additions & 3 deletions dje/tests/testfiles/test_dataset_pp_only.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@
"vcs_url": "",
"code_view_url": "",
"bug_tracking_url": "",
"md5": "",
"sha1": "",
"sha256": "",
"sha512": "",
"filename": "systemu-2.5.2.gem",
"download_url": "https://s3.amazonaws.com/production.s3.rubygems.org/gems/systemu-2.5.2.gem",
"sha1": "",
"md5": "",
"size": null,
"release_date": null,
"primary_language": "",
Expand Down Expand Up @@ -98,7 +98,8 @@
"nexB",
"addd9c5d-a5ec-48ec-a565-ddb81092f49d"
],
"contact": ""
"contact": "",
"vulnerabilities_risk_threshold": null
}
},
{
Expand Down
1 change: 1 addition & 0 deletions product_portfolio/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ class ProductAdmin(
"is_active",
"configuration_status",
"contact",
"vulnerabilities_risk_threshold",
"get_feature_datalist",
)
},
Expand Down
1 change: 1 addition & 0 deletions product_portfolio/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ class Meta:
"primary_language",
"admin_notes",
"notice_text",
"vulnerabilities_risk_threshold",
"created_date",
"last_modified_date",
)
Expand Down
3 changes: 3 additions & 0 deletions product_portfolio/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ class Meta:
"configuration_status",
"contact",
"keywords",
"vulnerabilities_risk_threshold",
]
field_classes = {
"owner": OwnerChoiceField,
Expand Down Expand Up @@ -170,6 +171,8 @@ def helper(self):
HTML("<hr>"),
Group("is_active", "configuration_status", "release_date"),
HTML("<hr>"),
Group("vulnerabilities_risk_threshold", HTML(""), HTML("")),
HTML("<hr>"),
Submit("submit", self.submit_label, css_class="btn-success"),
self.save_as_new_submit,
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.9 on 2024-12-13 13:01

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('product_portfolio', '0008_productdependency_is_resolved_to_is_pinned'),
]

operations = [
migrations.AddField(
model_name='product',
name='vulnerabilities_risk_threshold',
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),
),
]
26 changes: 25 additions & 1 deletion product_portfolio/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,17 @@ class Product(BaseProductMixin, FieldChangesMixin, KeywordsMixin, DataspacedMode
),
)

vulnerabilities_risk_threshold = models.DecimalField(
null=True,
blank=True,
max_digits=3,
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."
),
)

licenses = models.ManyToManyField(
to="license_library.License",
through="ProductAssignedLicense",
Expand Down Expand Up @@ -338,6 +349,16 @@ def all_packages(self):
def vulnerability_count(self):
return self.get_vulnerability_qs().count()

def get_vulnerabilities_risk_threshold(self):
"""
Return the local vulnerabilities_risk_threshold value when defined on the
Product or look into the Dataspace configuration.
"""
risk_threshold = self.vulnerabilities_risk_threshold
if not risk_threshold:
risk_threshold = self.dataspace.get_configuration("vulnerabilities_risk_threshold")
return risk_threshold

def get_merged_descendant_ids(self):
"""
Return a list of Component ids collected on the Product descendants:
Expand Down Expand Up @@ -527,7 +548,7 @@ def fetch_vulnerabilities(self):
"""Fetch and update the vulnerabilties of all the Package of this Product."""
return fetch_for_packages(self.all_packages, self.dataspace)

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

if risk_threshold:
vulnerability_qs = vulnerability_qs.filter(risk_score__gte=risk_threshold)

if prefetch_related_packages:
package_qs = Package.objects.filter(product=self).only_rendering_fields()
analysis_qs = VulnerabilityAnalysis.objects.filter(product=self).select_related(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
{% load as_icon from dje_tags %}

{% include 'tabs/pagination.html' %}

{% if risk_threshold %}
<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">
A risk threshold filter at "{{ risk_threshold }}" is currently applied.
<a class="ms-1" href="?vulnerabilities-bypass_risk_threshold=Yes#vulnerabilities">Click here to see all vulnerabilities.</a>
</small>
{% endif %}

<table class="table table-bordered table-md text-break">
{% include 'includes/object_list_table_header.html' with filter=filterset include_actions=True %}
<tbody>
Expand Down Expand Up @@ -76,7 +84,7 @@
</td>
<td class="text-center">
{% if package.vulnerability_analysis.is_reachable %}
<i class="fa-solid fa-circle-radiation text-danger fs-5" data-bs-toggle="tooltip" title="Vulnerability is reachable"></i>
<i class="fa-solid fa-circle-radiation text-danger fs-6" data-bs-toggle="tooltip" title="Vulnerability is reachable"></i>
{% elif package.vulnerability_analysis.is_reachable is False %}
<i class="fa-solid fa-bug-slash" data-bs-toggle="tooltip" title="Vulnerability is NOT reachable"></i>
{% endif %}
Expand Down
23 changes: 21 additions & 2 deletions product_portfolio/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,8 +508,12 @@ def test_product_model_improve_packages_from_purldb(self, mock_update_from_purld
def test_product_model_get_vulnerability_qs(self):
package1 = make_package(self.dataspace)
package2 = make_package(self.dataspace)
vulnerability1 = make_vulnerability(self.dataspace, affecting=[package1, package2])
vulnerability2 = make_vulnerability(self.dataspace, affecting=[package1, package2])
vulnerability1 = make_vulnerability(
self.dataspace, affecting=[package1, package2], risk_score=10.0
)
vulnerability2 = make_vulnerability(
self.dataspace, affecting=[package1, package2], risk_score=1.0
)
make_product_package(self.product1, package=package1)
make_product_package(self.product1, package=package2)

Expand All @@ -519,6 +523,12 @@ def test_product_model_get_vulnerability_qs(self):
self.assertIn(vulnerability1, queryset)
self.assertIn(vulnerability2, queryset)

queryset = self.product1.get_vulnerability_qs(risk_threshold=5.0)
# Makeing sure the distinct() is properly applied
self.assertEqual(1, len(queryset))
self.assertIn(vulnerability1, queryset)
self.assertNotIn(vulnerability2, queryset)

def test_product_model_vulnerability_count_property(self):
self.assertEqual(0, self.product1.vulnerability_count)

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

def test_product_model_get_vulnerabilities_risk_threshold(self):
self.assertIsNone(self.product1.get_vulnerabilities_risk_threshold())

self.product1.dataspace.set_configuration("vulnerabilities_risk_threshold", 5.0)
self.assertEqual(5.0, self.product1.get_vulnerabilities_risk_threshold())

self.product1.update(vulnerabilities_risk_threshold=10.0)
self.assertEqual(10.0, self.product1.get_vulnerabilities_risk_threshold())

def test_productcomponent_model_license_expression_handle_assigned_licenses(self):
p1 = ProductComponent.objects.create(
product=self.product1, name="p1", dataspace=self.dataspace
Expand Down
25 changes: 24 additions & 1 deletion product_portfolio/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,32 @@ def test_product_portfolio_tab_vulnerability_view_queries(self):
make_vulnerability_analysis(product_package2, vulnerability2)

url = product1.get_url("tab_vulnerabilities")
with self.assertNumQueries(9):
with self.assertNumQueries(10):
self.client.get(url)

def test_product_portfolio_tab_vulnerability_risk_threshold(self):
self.client.login(username="nexb_user", password="secret")

p1 = make_package(self.dataspace)
vulnerability1 = make_vulnerability(self.dataspace, affecting=[p1], risk_score=1.0)
vulnerability2 = make_vulnerability(self.dataspace, affecting=[p1], risk_score=5.0)
product1 = make_product(self.dataspace)
make_product_package(product1, package=p1)
url = product1.get_url("tab_vulnerabilities")

response = self.client.get(url)
self.assertContains(response, vulnerability1.vcid)
self.assertContains(response, vulnerability2.vcid)
self.assertContains(response, "2 results")
self.assertNotContains(response, "A risk threshold filter at")

product1.update(vulnerabilities_risk_threshold=3.0)
response = self.client.get(url)
self.assertNotContains(response, vulnerability1.vcid)
self.assertContains(response, vulnerability2.vcid)
self.assertContains(response, "1 results")
self.assertContains(response, 'A risk threshold filter at "3.0" is currently applied.')

def test_product_portfolio_tab_vulnerability_view_analysis_rendering(self):
self.client.login(username="nexb_user", password="secret")
# Each have a unique vulnerability, and p1 p2 are sharing a common one.
Expand Down
Loading

0 comments on commit 6760696

Please sign in to comment.