Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add vulnerabilities_risk_threshold fields #97 #210

Merged
merged 8 commits into from
Dec 17, 2024
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