diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0c69f806..cb2d070a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,11 @@ Release notes - Rename ProductDependency is_resolved to is_pinned. https://github.com/aboutcode-org/dejacode/issues/189 +- Add new fields on the Vulnerability model: `exploitability`, `weighted_severity`, + `risk_score`. The field are displayed in all relevant part of the UI where + vulnerability data is available. + https://github.com/aboutcode-org/dejacode/issues/98 + ### Version 5.2.1 - Fix the models documentation navigation. diff --git a/component_catalog/importers.py b/component_catalog/importers.py index be05c3ae..ca588f75 100644 --- a/component_catalog/importers.py +++ b/component_catalog/importers.py @@ -41,7 +41,7 @@ from policy.models import UsagePolicy from product_portfolio.models import ProductComponent from product_portfolio.models import ProductPackage -from vulnerabilities.fetch import fetch_for_queryset +from vulnerabilities.fetch import fetch_for_packages keywords_help = ( get_help_text(Component, "keywords") @@ -433,7 +433,7 @@ def save_all(self): if self.dataspace.enable_vulnerablecodedb_access: package_pks = [package.pk for package in self.results["added"]] package_qs = Package.objects.scope(dataspace=self.dataspace).filter(pk__in=package_pks) - fetch_for_queryset(package_qs, self.dataspace) + fetch_for_packages(package_qs, self.dataspace) class SubcomponentImportForm( diff --git a/component_catalog/migrations/0010_component_risk_score_package_risk_score.py b/component_catalog/migrations/0010_component_risk_score_package_risk_score.py new file mode 100644 index 00000000..5935333d --- /dev/null +++ b/component_catalog/migrations/0010_component_risk_score_package_risk_score.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.9 on 2024-11-18 10:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('component_catalog', '0009_componentaffectedbyvulnerability_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='component', + name='risk_score', + 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), + ), + migrations.AddField( + model_name='package', + name='risk_score', + 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), + ), + ] diff --git a/component_catalog/models.py b/component_catalog/models.py index 9c60e9c2..35b46eb7 100644 --- a/component_catalog/models.py +++ b/component_catalog/models.py @@ -1675,6 +1675,7 @@ def only_rendering_fields(self): *PACKAGE_URL_FIELDS, "filename", "license_expression", + "risk_score", "dataspace__name", "dataspace__show_usage_policy_in_user_views", ) diff --git a/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html b/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html index 77b010f8..9fa439bd 100644 --- a/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html +++ b/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html @@ -1,4 +1,14 @@ {% load i18n %} +
+
+ + Risk score + +
+
+ {% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=package.risk_score only %} +
+
@@ -7,19 +17,24 @@ {% trans 'Affected by' %} - - - + - + + + - + {% if product.dataspace.enable_vulnerablecodedb_access %} + + {% endif %} @@ -113,6 +131,13 @@ + {% if product.dataspace.enable_vulnerablecodedb_access %} + + {% endif %} {% if relation.package and display_scan_features %} diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_vulnerabilities.html index 1931d05d..39633391 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_vulnerabilities.html @@ -16,19 +16,28 @@ {{ vulnerability.vulnerability_id }} {% endif %} +
+ {% include 'component_catalog/includes/vulnerability_aliases.html' with aliases=vulnerability.aliases only %} +
+ + - {% empty %} diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 505a84f2..7c2e8fcc 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -300,10 +300,10 @@ def test_product_portfolio_detail_view_tab_vulnerability_view_filters(self): self.client.login(username="nexb_user", password="secret") url = self.product1.get_url("tab_vulnerabilities") response = self.client.get(url) - self.assertContains(response, "?vulnerabilities-max_score=#vulnerabilities") - self.assertContains(response, "?vulnerabilities-sort=max_score#vulnerabilities") - response = self.client.get(url + "?vulnerabilities-sort=max_score#vulnerabilities") - self.assertContains(response, "?vulnerabilities-sort=-max_score#vulnerabilities") + self.assertContains(response, "?vulnerabilities-risk_score=#vulnerabilities") + self.assertContains(response, "?vulnerabilities-sort=risk_score#vulnerabilities") + response = self.client.get(url + "?vulnerabilities-sort=risk_score#vulnerabilities") + self.assertContains(response, "?vulnerabilities-sort=-risk_score#vulnerabilities") @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.is_configured") def test_product_portfolio_detail_view_tab_vulnerability_label(self, mock_is_configured): @@ -341,8 +341,11 @@ def test_product_portfolio_detail_view_object_type_filter_in_inventory_tab(self) pc2_custom = ProductComponent.objects.create( product=self.product1, name="temporary name", is_modified=True, dataspace=self.dataspace ) + self.package1.update(risk_score=1.0) pp1 = ProductPackage.objects.create( - product=self.product1, package=self.package1, dataspace=self.dataspace + product=self.product1, + package=self.package1, + dataspace=self.dataspace, ) response = self.client.get(self.product1.get_absolute_url()) @@ -405,6 +408,12 @@ def test_product_portfolio_detail_view_object_type_filter_in_inventory_tab(self) self.assertIn(pc2_custom, pc_filterset) self.assertNotIn(pp1, pc_filterset) + response = self.client.get(url + "?inventory-risk_score=low") + pc_filterset = response.context["inventory_items"][""] + self.assertNotIn(pc_valid, pc_filterset) + self.assertNotIn(pc2_custom, pc_filterset) + self.assertIn(pp1, pc_filterset) + def test_product_portfolio_detail_view_review_status_filter_in_inventory_tab(self): self.client.login(username="nexb_user", password="secret") diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 0a0a20fc..ee74b54d 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -25,7 +25,6 @@ from django.core.paginator import Paginator from django.db import transaction from django.db.models import Count -from django.db.models import F from django.db.models import Prefetch from django.db.models.functions import Lower from django.forms import modelformset_factory @@ -60,6 +59,7 @@ from component_catalog.license_expression_dje import parse_expression from component_catalog.models import Component from component_catalog.models import Package +from component_catalog.models import Subcomponent from dejacode_toolkit.purldb import PurlDB from dejacode_toolkit.scancodeio import ScanCodeIO from dejacode_toolkit.scancodeio import get_hash_uid @@ -75,6 +75,7 @@ from dje.models import History from dje.templatetags.dje_tags import urlize_target_blank from dje.utils import chunked +from dje.utils import get_help_text from dje.utils import get_object_compare_diff from dje.utils import group_by_simple from dje.utils import is_uuid4 @@ -119,13 +120,16 @@ from product_portfolio.forms import ProductPackageInlineForm from product_portfolio.forms import PullProjectDataForm from product_portfolio.forms import TableInlineFormSetHelper +from product_portfolio.models import RELATION_LICENSE_EXPRESSION_HELP_TEXT from product_portfolio.models import CodebaseResource from product_portfolio.models import Product from product_portfolio.models import ProductComponent from product_portfolio.models import ProductDependency from product_portfolio.models import ProductPackage +from product_portfolio.models import ProductRelationshipMixin from product_portfolio.models import ScanCodeProject from vulnerabilities.filters import VulnerabilityFilterSet +from vulnerabilities.models import AffectedByVulnerabilityMixin from vulnerabilities.models import Vulnerability @@ -872,6 +876,15 @@ def get_context_data(self, **kwargs): } ) + context["help_texts"] = { + "purpose": get_help_text(Subcomponent, "purpose"), + "license_expression": RELATION_LICENSE_EXPRESSION_HELP_TEXT, + "review_status": get_help_text(ProductRelationshipMixin, "review_status"), + "is_deployed": get_help_text(ProductRelationshipMixin, "is_deployed"), + "is_modified": get_help_text(ProductRelationshipMixin, "is_modified"), + "risk_score": get_help_text(AffectedByVulnerabilityMixin, "risk_score"), + } + return context @staticmethod @@ -1096,10 +1109,11 @@ class ProductTabVulnerabilitiesView( filterset_class = VulnerabilityFilterSet table_headers = ( Header("vulnerability_id", _("Vulnerability")), - Header("aliases", _("Aliases")), - Header("max_score", _("Score"), help_text="Severity score range", filter="max_score"), - Header("summary", _("Summary")), Header("affected_packages", _("Affected packages"), help_text="Affected product packages"), + Header("exploitability", _("Exploitability"), filter="exploitability"), + Header("weighted_severity", _("Severity"), filter="weighted_severity"), + Header("risk_score", _("Risk"), filter="risk_score"), + Header("summary", _("Summary")), ) def get_context_data(self, **kwargs): @@ -1110,10 +1124,7 @@ def get_context_data(self, **kwargs): package_qs = Package.objects.filter(product=product).only_rendering_fields() vulnerability_qs = base_vulnerability_qs.prefetch_related( Prefetch("affected_packages", package_qs) - ).order_by( - F("max_score").desc(nulls_last=True), - "-min_score", - ) + ).order_by_risk() self.filterset = self.filterset_class( self.request.GET, diff --git a/reporting/tests/test_models.py b/reporting/tests/test_models.py index efbe0604..6a973d65 100644 --- a/reporting/tests/test_models.py +++ b/reporting/tests/test_models.py @@ -2016,6 +2016,7 @@ def test_get_model_data_for_component_column_template(self): {"group": "Direct Fields", "value": "reference_notes", "label": "reference_notes"}, {"group": "Direct Fields", "label": "release_date", "value": "release_date"}, {"group": "Direct Fields", "label": "request_count", "value": "request_count"}, + {"group": "Direct Fields", "label": "risk_score", "value": "risk_score"}, { "group": "Direct Fields", "label": "sublicense_allowed", diff --git a/vulnerabilities/fetch.py b/vulnerabilities/fetch.py index e5fe9bc9..749f6b00 100644 --- a/vulnerabilities/fetch.py +++ b/vulnerabilities/fetch.py @@ -20,7 +20,7 @@ from vulnerabilities.models import Vulnerability -def fetch_from_vulnerablecode(dataspace, batch_size, timeout, log_func=None): +def fetch_from_vulnerablecode(dataspace, batch_size, update, timeout, log_func=None): start_time = timer() vulnerablecode = VulnerableCode(dataspace) if not vulnerablecode.is_configured(): @@ -38,7 +38,14 @@ def fetch_from_vulnerablecode(dataspace, batch_size, timeout, log_func=None): if log_func: log_func(f"{package_count} Packages in the queue.") - created = fetch_for_queryset(package_qs, dataspace, batch_size, timeout, log_func) + created = fetch_for_packages( + queryset=package_qs, + dataspace=dataspace, + batch_size=batch_size, + update=update, + timeout=timeout, + log_func=log_func, + ) run_time = timer() - start_time if log_func: log_func(f"+ Created {intcomma(created)} vulnerabilities") @@ -48,7 +55,9 @@ def fetch_from_vulnerablecode(dataspace, batch_size, timeout, log_func=None): dataspace.save(update_fields=["vulnerabilities_updated_at"]) -def fetch_for_queryset(queryset, dataspace, batch_size=50, timeout=None, log_func=None): +def fetch_for_packages( + queryset, dataspace, batch_size=50, update=True, timeout=None, log_func=None +): object_count = queryset.count() if object_count < 1: return @@ -56,6 +65,7 @@ def fetch_for_queryset(queryset, dataspace, batch_size=50, timeout=None, log_fun vulnerablecode = VulnerableCode(dataspace) vulnerability_qs = Vulnerability.objects.scope(dataspace) created_vulnerabilities = 0 + updated_vulnerabilities = 0 for index, batch in enumerate(chunked_queryset(queryset, batch_size), start=1): if log_func: @@ -88,6 +98,16 @@ def fetch_for_queryset(queryset, dataspace, batch_size=50, timeout=None, log_fun data=vulnerability_data, ) created_vulnerabilities += 1 + elif update: + updated_fields = vulnerability.update_from_data( + user=None, data=vulnerability_data, override=True + ) + if updated_fields: + updated_vulnerabilities += 1 + vulnerability.add_affected_packages(affected_packages) + if package_risk_score := vc_entry.get("risk_score"): + affected_packages.update(risk_score=package_risk_score) + return created_vulnerabilities diff --git a/vulnerabilities/filters.py b/vulnerabilities/filters.py index e1ae9e5a..e6f627fd 100644 --- a/vulnerabilities/filters.py +++ b/vulnerabilities/filters.py @@ -17,6 +17,13 @@ from dje.widgets import SortDropDownWidget from vulnerabilities.models import Vulnerability +RISK_SCORE_RANGES = { + "low": (0.1, 2.9), + "medium": (3.0, 5.9), + "high": (6.0, 7.9), + "critical": (8.0, 10.0), +} + class NullsLastOrderingFilter(django_filters.OrderingFilter): """ @@ -42,17 +49,26 @@ def filter(self, qs, value): return qs.order_by(*ordering) -vulnerability_score_ranges = { - "low": (0.1, 3), - "medium": (4.0, 6.9), - "high": (7.0, 8.9), - "critical": (9.0, 10.0), -} +class ScoreRangeFilter(django_filters.ChoiceFilter): + def __init__(self, *args, **kwargs): + score_ranges = kwargs.pop("score_ranges", {}) + choices = [ + (key, f"{key.capitalize()} ({value[0]} - {value[1]})") + for key, value in score_ranges.items() + ] + kwargs["choices"] = choices + super().__init__(*args, **kwargs) + self.score_ranges = score_ranges -SCORE_CHOICES = [ - (key, f"{key.capitalize()} ({value[0]} - {value[1]})") - for key, value in vulnerability_score_ranges.items() -] + def filter(self, qs, value): + if value in self.score_ranges: + low, high = self.score_ranges[value] + filters = { + f"{self.field_name}__gte": low, + f"{self.field_name}__lte": high, + } + return qs.filter(**filters) + return qs class VulnerabilityFilterSet(DataspacedFilterSet): @@ -63,8 +79,9 @@ class VulnerabilityFilterSet(DataspacedFilterSet): sort = NullsLastOrderingFilter( label=_("Sort"), fields=[ - "max_score", - "min_score", + "exploitability", + "weighted_severity", + "risk_score", "affected_products_count", "affected_packages_count", "fixed_packages_count", @@ -73,25 +90,24 @@ class VulnerabilityFilterSet(DataspacedFilterSet): ], widget=SortDropDownWidget, ) - max_score = django_filters.ChoiceFilter( - choices=SCORE_CHOICES, - method="filter_by_score_range", - label="Score Range", - help_text="Select a score range to filter.", + weighted_severity = ScoreRangeFilter( + label=_("Severity"), + score_ranges=RISK_SCORE_RANGES, + ) + risk_score = ScoreRangeFilter( + label=_("Risk score"), + score_ranges=RISK_SCORE_RANGES, ) class Meta: model = Vulnerability fields = [ "q", + "exploitability", ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.filters["max_score"].extra["widget"] = DropDownRightWidget(anchor=self.anchor) - - def filter_by_score_range(self, queryset, name, value): - if value in vulnerability_score_ranges: - low, high = vulnerability_score_ranges[value] - return queryset.filter(max_score__gte=low, max_score__lte=high) - return queryset + self.filters["exploitability"].extra["widget"] = DropDownRightWidget(anchor=self.anchor) + self.filters["weighted_severity"].extra["widget"] = DropDownRightWidget(anchor=self.anchor) + self.filters["risk_score"].extra["widget"] = DropDownRightWidget(anchor=self.anchor) diff --git a/vulnerabilities/management/commands/fetchvulnerabilities.py b/vulnerabilities/management/commands/fetchvulnerabilities.py index 741be62d..cb8f22bb 100644 --- a/vulnerabilities/management/commands/fetchvulnerabilities.py +++ b/vulnerabilities/management/commands/fetchvulnerabilities.py @@ -43,4 +43,10 @@ def handle(self, *args, **options): if not vulnerablecode.is_configured(): raise CommandError("VulnerableCode is not configured.") - fetch_from_vulnerablecode(self.dataspace, batch_size, timeout, log_func=self.stdout.write) + fetch_from_vulnerablecode( + self.dataspace, + batch_size=batch_size, + update=True, + timeout=timeout, + log_func=self.stdout.write, + ) diff --git a/vulnerabilities/migrations/0002_remove_vulnerability_max_score_and_more.py b/vulnerabilities/migrations/0002_remove_vulnerability_max_score_and_more.py new file mode 100644 index 00000000..8fa9adfc --- /dev/null +++ b/vulnerabilities/migrations/0002_remove_vulnerability_max_score_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.9 on 2024-11-18 10:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vulnerabilities', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='vulnerability', + name='max_score', + ), + migrations.RemoveField( + model_name='vulnerability', + name='min_score', + ), + migrations.AddField( + model_name='vulnerability', + name='exploitability', + field=models.DecimalField(blank=True, choices=[(0.5, 'No exploits known'), (1.0, 'Potential exploits'), (2.0, 'Known exploits')], decimal_places=1, help_text='Exploitability refers to the potential or probability of a software package vulnerability being exploited by malicious actors to compromise systems, applications, or networks. It is determined automatically by discovery of exploits.', max_digits=2, null=True), + ), + migrations.AddField( + model_name='vulnerability', + name='risk_score', + field=models.DecimalField(blank=True, decimal_places=1, help_text='Risk score from 0.0 to 10.0, with higher values indicating greater vulnerability risk. This score is the maximum of the weighted severity multiplied by exploitability, capped at 10.', max_digits=3, null=True), + ), + migrations.AddField( + model_name='vulnerability', + name='weighted_severity', + field=models.DecimalField(blank=True, decimal_places=1, help_text='Weighted severity is the highest value calculated by multiplying each severity by its corresponding weight, divided by 10.', max_digits=3, null=True), + ), + ] diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index dbd789b4..726801f9 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -40,6 +40,13 @@ def with_affected_packages_count(self): affected_packages_count=Count("affected_packages", distinct=True), ) + def order_by_risk(self): + return self.order_by( + models.F("risk_score").desc(nulls_last=True), + models.F("weighted_severity").desc(nulls_last=True), + models.F("exploitability").desc(nulls_last=True), + ) + class Vulnerability(HistoryDateFieldsMixin, DataspacedModel): """ @@ -94,15 +101,45 @@ class Vulnerability(HistoryDateFieldsMixin, DataspacedModel): output_field=models.IntegerField(), db_persist=True, ) - min_score = models.FloatField( + EXPLOITABILITY_CHOICES = [ + (0.5, _("No exploits known")), + (1.0, _("Potential exploits")), + (2.0, _("Known exploits")), + ] + exploitability = models.DecimalField( null=True, blank=True, - help_text=_("The minimum score of the range."), + max_digits=2, + decimal_places=1, + choices=EXPLOITABILITY_CHOICES, + help_text=_( + "Exploitability refers to the potential or probability of a software " + "package vulnerability being exploited by malicious actors to compromise " + "systems, applications, or networks. " + "It is determined automatically by discovery of exploits." + ), ) - max_score = models.FloatField( + weighted_severity = models.DecimalField( null=True, blank=True, - help_text=_("The maximum score of the range."), + max_digits=3, + decimal_places=1, + help_text=_( + "Weighted severity is the highest value calculated by multiplying each " + "severity by its corresponding weight, divided by 10." + ), + ) + risk_score = models.DecimalField( + null=True, + blank=True, + max_digits=3, + decimal_places=1, + help_text=_( + "Risk score from 0.0 to 10.0, with higher values indicating greater " + "vulnerability risk. " + "This score is the maximum of the weighted severity multiplied by " + "exploitability, capped at 10." + ), ) objects = DataspacedManager.from_queryset(VulnerabilityQuerySet)() @@ -148,32 +185,8 @@ def add_affected_components(self, components): through_defaults = {"dataspace_id": self.dataspace_id} self.affected_components.add(*components, through_defaults=through_defaults) - @staticmethod - def range_to_values(self, range_str): - try: - min_score, max_score = range_str.split("-") - return float(min_score.strip()), float(max_score.strip()) - except Exception: - return - @classmethod def create_from_data(cls, dataspace, data, validate=False, affecting=None): - # Computing the min_score and max_score from the `references` as those data - # are not provided by the VulnerableCode API. - # https://github.com/aboutcode-org/vulnerablecode/issues/1573 - # severity_range_score = data.get("severity_range_score") - # if severity_range_score: - # min_score, max_score = self.range_to_values(severity_range_score) - # data["min_score"] = min_score - # data["max_score"] = max_score - - severities = [ - score for reference in data.get("references") for score in reference.get("scores", []) - ] - if scores := cls.get_severity_scores(severities): - data["min_score"] = min(scores) - data["max_score"] = max(scores) - instance = super().create_from_data(user=dataspace, data=data, validate=False) if affecting: @@ -181,28 +194,6 @@ def create_from_data(cls, dataspace, data, validate=False, affecting=None): return instance - @staticmethod - def get_severity_scores(severities): - score_map = { - "low": [0.1, 3], - "moderate": [4.0, 6.9], - "medium": [4.0, 6.9], - "high": [7.0, 8.9], - "important": [7.0, 8.9], - "critical": [9.0, 10.0], - } - - consolidated_scores = [] - for severity in severities: - score = severity.get("value") - try: - consolidated_scores.append(float(score)) - except ValueError: - if score_range := score_map.get(score.lower(), None): - consolidated_scores.extend(score_range) - - return consolidated_scores - def as_cyclonedx(self, affected_instances): affects = [ cdx_vulnerability.BomTarget(ref=instance.cyclonedx_bom_ref) @@ -378,6 +369,17 @@ class AffectedByVulnerabilityMixin(models.Model): related_name="affected_%(class)ss", help_text=_("Vulnerabilities affecting this object."), ) + # Based on vulnerablecode.vulnerabilities.models.Package + risk_score = models.DecimalField( + null=True, + blank=True, + max_digits=3, + decimal_places=1, + help_text=_( + "Risk score between 0.0 and 10.0, where higher values " + "indicate greater vulnerability risk for the package." + ), + ) class Meta: abstract = True diff --git a/vulnerabilities/templates/vulnerabilities/includes/exploitability.html b/vulnerabilities/templates/vulnerabilities/includes/exploitability.html new file mode 100644 index 00000000..be644eba --- /dev/null +++ b/vulnerabilities/templates/vulnerabilities/includes/exploitability.html @@ -0,0 +1,11 @@ +{% if instance.exploitability %} + + {{ instance.get_exploitability_display }} + +{% endif %} \ No newline at end of file diff --git a/vulnerabilities/templates/vulnerabilities/includes/risk_score_badge.html b/vulnerabilities/templates/vulnerabilities/includes/risk_score_badge.html new file mode 100644 index 00000000..832315a9 --- /dev/null +++ b/vulnerabilities/templates/vulnerabilities/includes/risk_score_badge.html @@ -0,0 +1,14 @@ +{% if risk_score %} + + {% if label %} + {{ label }} + {% endif %} + {{ risk_score }} + +{% endif %} \ No newline at end of file diff --git a/vulnerabilities/templates/vulnerabilities/tables/vulnerability_list_table.html b/vulnerabilities/templates/vulnerabilities/tables/vulnerability_list_table.html index 012e9c6b..4b5aae94 100644 --- a/vulnerabilities/templates/vulnerabilities/tables/vulnerability_list_table.html +++ b/vulnerabilities/templates/vulnerabilities/tables/vulnerability_list_table.html @@ -19,19 +19,9 @@ {{ vulnerability.vulnerability_id }} {% endif %} - - - + + +
- - {% trans 'Aliases' %} + + + {% trans 'Summary' %} - - {% trans 'Score' %} + + + {% trans 'Exploitability' %} - - {% trans 'Summary' %} + + + {% trans 'Severity' %} + + + + {% trans 'Risk' %} @@ -43,19 +58,9 @@ {{ vulnerability.vulnerability_id }} {% endif %} - - - {% include 'component_catalog/includes/vulnerability_aliases.html' with aliases=vulnerability.aliases only %} - - {% if vulnerability.min_score %} - {{ vulnerability.min_score }} - - {% endif %} - {% if vulnerability.max_score %} - - {{ vulnerability.max_score }} - - {% endif %} +
+ {% include 'component_catalog/includes/vulnerability_aliases.html' with aliases=vulnerability.aliases only %} +
{% if vulnerability.summary %} @@ -69,6 +74,15 @@ {% endif %} {% endif %} + {% include 'vulnerabilities/includes/exploitability.html' with instance=vulnerability only %} + + {{ vulnerability.weighted_severity|default_if_none:"" }} + + {% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=vulnerability.risk_score only %} + {% if vulnerability.fixed_packages_html %} {{ vulnerability.fixed_packages_html }} diff --git a/component_catalog/tests/test_importers.py b/component_catalog/tests/test_importers.py index fefb5412..9f24e251 100644 --- a/component_catalog/tests/test_importers.py +++ b/component_catalog/tests/test_importers.py @@ -1397,9 +1397,9 @@ def test_package_import_add_to_product(self): self.assertContains(response, expected3) self.assertContains(response, expected4) - @mock.patch("component_catalog.importers.fetch_for_queryset") - def test_package_import_fetch_vulnerabilities(self, mock_fetch_for_queryset): - mock_fetch_for_queryset.return_value = None + @mock.patch("component_catalog.importers.fetch_for_packages") + def test_package_import_fetch_vulnerabilities(self, mock_fetch_for_packages): + mock_fetch_for_packages.return_value = None self.dataspace.enable_vulnerablecodedb_access = True self.dataspace.save() @@ -1407,7 +1407,7 @@ def test_package_import_fetch_vulnerabilities(self, mock_fetch_for_queryset): importer = PackageImporter(self.super_user, file) importer.save_all() self.assertEqual(2, len(importer.results["added"])) - mock_fetch_for_queryset.assert_called() + mock_fetch_for_packages.assert_called() class SubcomponentImporterTestCase(TestCase): diff --git a/component_catalog/tests/test_models.py b/component_catalog/tests/test_models.py index 5f7b2cbb..fbbbb5fb 100644 --- a/component_catalog/tests/test_models.py +++ b/component_catalog/tests/test_models.py @@ -1683,6 +1683,10 @@ def test_package_model_update_from_data(self): package.refresh_from_db() self.assertEqual(new_data["filename"], package.filename) + new_data = {"filename": "new_filename2"} + updated_fields = package.update_from_data(user=None, data=new_data, override=True) + self.assertEqual(["filename"], updated_fields) + @mock.patch("component_catalog.models.collect_package_data") def test_package_model_create_from_url(self, mock_collect): self.assertIsNone(Package.create_from_url(url=" ", user=self.user)) diff --git a/component_catalog/views.py b/component_catalog/views.py index 05bca001..2643ce2d 100644 --- a/component_catalog/views.py +++ b/component_catalog/views.py @@ -251,7 +251,7 @@ class TabVulnerabilityMixin: template = "component_catalog/tabs/tab_vulnerabilities.html" def tab_vulnerabilities(self): - vulnerabilities_qs = self.object.affected_by_vulnerabilities.all() + vulnerabilities_qs = self.object.affected_by_vulnerabilities.order_by_risk() if not vulnerabilities_qs: return diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index 81ef895f..42d1c652 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -40,6 +40,9 @@ a.dropdown-item:hover { .fs-85pct { font-size: 85%; } +.fs-110pct { + font-size: 110%; +} .header { margin-bottom: 1rem; } @@ -86,6 +89,9 @@ table.text-break thead { word-wrap: initial!important; word-break: initial!important; } +.bg-warning-orange { + background-color: var(--bs-orange); +} /* -- Dark there fixes -- */ [data-bs-theme=dark] .btn-outline-dark { --bs-btn-color: var(--bs-tertiary-color); @@ -380,14 +386,20 @@ table.vulnerabilities-table .column-summary { #tab_vulnerabilities .column-vulnerability_id { width: 210px; } -#tab_vulnerabilities .column-aliases { - width: 210px; +#tab_vulnerabilities .column-affected_packages { + min-width: 300px; } -#tab_vulnerabilities .column-max_score { - width: 105px; +#tab_vulnerabilities .column-exploitability { + width: 150px; } -#tab_vulnerabilities .column-column-affected_packages { - width: 320px; +#tab_vulnerabilities .column-weighted_severity { + width: 115px; +} +#tab_vulnerabilities .column-risk_score { + width: 95px; +} +#tab_vulnerabilities .column-summary { + width: 300px; } /* -- Dependency tab -- */ diff --git a/dje/copier.py b/dje/copier.py index 385d510c..e3cfe4ab 100644 --- a/dje/copier.py +++ b/dje/copier.py @@ -66,6 +66,7 @@ "project_uuid", "default_assignee", "affected_by_vulnerabilities", + "risk_score", ] diff --git a/dje/models.py b/dje/models.py index 6d4fa452..e330520c 100644 --- a/dje/models.py +++ b/dje/models.py @@ -777,6 +777,11 @@ def update_from_data(self, user, data, override=False): """ Update this object instance with the provided `data`. The `save()` method is called only if at least one field was modified. + + The user is optional, providing None, as some context of automatic update are + not associated to a specific user. + We do not want to promote this as the default behavior thus we keep the user + a required parameter. """ model_fields = self.model_fields() updated_fields = [] @@ -796,8 +801,11 @@ def update_from_data(self, user, data, override=False): updated_fields.append(field_name) if updated_fields: - self.last_modified_by = user - self.save(update_fields=[*updated_fields, "last_modified_by"]) + if user: + self.last_modified_by = user + self.save(update_fields=[*updated_fields, "last_modified_by"]) + else: + self.save(update_fields=updated_fields) return updated_fields diff --git a/dje/tasks.py b/dje/tasks.py index 806cdd89..bcf0325e 100644 --- a/dje/tasks.py +++ b/dje/tasks.py @@ -312,4 +312,4 @@ def update_vulnerabilities(): for dataspace in dataspace_qs: logger.info(f"fetch_vulnerabilities for datapsace={dataspace}") - fetch_from_vulnerablecode(dataspace, batch_size=50, timeout=60) + fetch_from_vulnerablecode(dataspace, batch_size=50, update=True, timeout=60) diff --git a/dje/tests/testfiles/test_dataset_cc_only.json b/dje/tests/testfiles/test_dataset_cc_only.json index cb14fe0a..b80388ec 100644 --- a/dje/tests/testfiles/test_dataset_cc_only.json +++ b/dje/tests/testfiles/test_dataset_cc_only.json @@ -44,6 +44,7 @@ "last_modified_date": "2011-08-24T09:20:01Z", "reference_notes": "", "usage_policy": null, + "risk_score": null, "declared_license_expression": "", "other_license_expression": "", "holder": "", @@ -114,6 +115,7 @@ "last_modified_date": "2011-08-24T09:20:01Z", "reference_notes": "", "usage_policy": null, + "risk_score": null, "declared_license_expression": "", "other_license_expression": "", "holder": "", @@ -280,6 +282,7 @@ "version": "", "qualifiers": "", "subpath": "", + "risk_score": null, "declared_license_expression": "", "other_license_expression": "", "holder": "", diff --git a/dje/tests/testfiles/test_dataset_pp_only.json b/dje/tests/testfiles/test_dataset_pp_only.json index fab48776..3c88ae44 100644 --- a/dje/tests/testfiles/test_dataset_pp_only.json +++ b/dje/tests/testfiles/test_dataset_pp_only.json @@ -16,6 +16,7 @@ "version": "", "qualifiers": "", "subpath": "", + "risk_score": null, "declared_license_expression": "", "other_license_expression": "", "holder": "", diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index e0fc5127..7a9785bc 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -31,6 +31,9 @@ from product_portfolio.models import ProductDependency from product_portfolio.models import ProductPackage from product_portfolio.models import ProductStatus +from vulnerabilities.filters import RISK_SCORE_RANGES +from vulnerabilities.filters import ScoreRangeFilter +from vulnerabilities.models import Vulnerability class ProductFilterSet(DataspacedFilterSet): @@ -119,6 +122,7 @@ class Meta: class BaseProductRelationFilterSet(DataspacedFilterSet): + field_name_prefix = None is_deployed = BooleanChoiceFilter( empty_label="All (Inventory)", choices=( @@ -130,9 +134,7 @@ class BaseProductRelationFilterSet(DataspacedFilterSet): right_align=True, ), ) - is_modified = BooleanChoiceFilter() - object_type = django_filters.CharFilter( method="filter_object_type", widget=DropDownWidget( @@ -145,6 +147,18 @@ class BaseProductRelationFilterSet(DataspacedFilterSet): ), ), ) + exploitability = django_filters.ChoiceFilter( + label=_("Exploitability"), + choices=Vulnerability.EXPLOITABILITY_CHOICES, + ) + weighted_severity = ScoreRangeFilter( + label=_("Severity"), + score_ranges=RISK_SCORE_RANGES, + ) + risk_score = ScoreRangeFilter( + label=_("Risk score"), + score_ranges=RISK_SCORE_RANGES, + ) @staticmethod def filter_object_type(queryset, name, value): @@ -176,8 +190,15 @@ def __init__(self, *args, **kwargs): anchor=self.anchor, right_align=True ) + field_name_prefix = self.field_name_prefix + for field_name in ["exploitability", "weighted_severity", "risk_score"]: + field = self.filters[field_name] + field.extra["widget"] = DropDownWidget(anchor=self.anchor) + field.field_name = f"{field_name_prefix}__{field_name}" + class ProductComponentFilterSet(BaseProductRelationFilterSet): + field_name_prefix = "component" q = SearchFilter( label=_("Search"), search_fields=[ @@ -226,6 +247,7 @@ class Meta: class ProductPackageFilterSet(BaseProductRelationFilterSet): + field_name_prefix = "package" q = SearchFilter( label=_("Search"), search_fields=[ @@ -265,10 +287,6 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet): ), ) - @staticmethod - def do_nothing(queryset, name, value): - return queryset - class Meta: model = ProductPackage fields = [ diff --git a/product_portfolio/models.py b/product_portfolio/models.py index da3eda4e..54c3582e 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -41,7 +41,7 @@ from dje.validators import generic_uri_validator from dje.validators import validate_url_segment from dje.validators import validate_version -from vulnerabilities.fetch import fetch_for_queryset +from vulnerabilities.fetch import fetch_for_packages from vulnerabilities.models import Vulnerability RELATION_LICENSE_EXPRESSION_HELP_TEXT = _( @@ -510,7 +510,7 @@ def improve_packages_from_purldb(self, user): def fetch_vulnerabilities(self): """Fetch and update the vulnerabilties of all the Package of this Product.""" - return fetch_for_queryset(self.all_packages, self.dataspace) + return fetch_for_packages(self.all_packages, self.dataspace) def get_vulnerability_qs(self, prefetch_related_packages=False): """Return a QuerySet of all Vulnerability instances related to this product""" @@ -542,7 +542,7 @@ class ProductItemPurpose( label = models.CharField( max_length=50, help_text=_( - "Concise name to identify the Purpose of the Product Component or " "Product Package." + "Concise name to identify the Purpose of the Product Component or Product Package." ), ) diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_inventory.html b/product_portfolio/templates/product_portfolio/tabs/tab_inventory.html index 4c37226b..400bc2f6 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_inventory.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_inventory.html @@ -40,30 +40,48 @@ {{ filter_productcomponent.form.object_type }} {% if product.dataspace.enable_vulnerablecodedb_access %} -
+ {{ filter_productcomponent.form.is_vulnerable }} -
+ {% endif %}
- {% trans 'Purpose' %} + + {% trans 'Purpose' %} + {{ filter_productcomponent.form.purpose }} - {% trans 'Concluded license' %} + + {% trans 'Concluded license' %} + - {% trans 'Review status' %} + + + {% trans 'Compliance status' %} + {{ filter_productcomponent.form.review_status }} - {% trans 'Deployed' %} + + {% trans 'Deployed' %} + {{ filter_productcomponent.form.is_deployed }} - {% trans 'Modified' %} + + {% trans 'Modified' %} + {{ filter_productcomponent.form.is_modified }} + + {% trans 'Risk' %} + + {{ filter_productcomponent.form.risk_score }} +
{{ relation.review_status|default_if_none:'' }} {{ relation.is_deployed|as_icon }} {{ relation.is_modified|as_icon }} + {% if relation.related_component_or_package.vulnerability_count %} + {% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=relation.related_component_or_package.risk_score only %} + {% endif %} +
- {% include 'component_catalog/includes/vulnerability_aliases.html' with aliases=vulnerability.aliases only %} +
    + {% for package in vulnerability.affected_packages.all %} +
  • + {{ package }} + {% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=package.risk_score label='risk' only %} +
  • + {% endfor %} +
- {% if vulnerability.min_score %} - {{ vulnerability.min_score }} - - {% endif %} - {% if vulnerability.max_score %} - - {{ vulnerability.max_score }} - - {% endif %} + {% include 'vulnerabilities/includes/exploitability.html' with instance=vulnerability only %} + + {{ vulnerability.weighted_severity|default_if_none:"" }} + + {% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=vulnerability.risk_score only %} {% if vulnerability.summary %} @@ -42,15 +51,6 @@ {% endif %} {% endif %} -
    - {% for package in vulnerability.affected_packages.all %} -
  • - {{ package }} -
  • - {% endfor %} -
-
- {% include 'component_catalog/includes/vulnerability_aliases.html' with aliases=vulnerability.aliases only %} - - {% if vulnerability.min_score %} - {{ vulnerability.min_score }} - - {% endif %} - {% if vulnerability.max_score %} - - {{ vulnerability.max_score }} - - {% endif %} +
+ {% include 'component_catalog/includes/vulnerability_aliases.html' with aliases=vulnerability.aliases only %} +
{% if vulnerability.summary %} @@ -45,6 +35,15 @@ {% endif %} {% endif %} + {% include 'vulnerabilities/includes/exploitability.html' with instance=vulnerability only %} + + {{ vulnerability.weighted_severity|default_if_none:"" }} + + {% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=vulnerability.risk_score only %} + {% if vulnerability.affected_products_count %} diff --git a/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json b/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json index 54c2b790..5b53f1fb 100644 --- a/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json +++ b/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json @@ -13,6 +13,7 @@ "qualifiers": {}, "subpath": "", "is_vulnerable": true, + "risk_score": 8.4, "next_non_vulnerable_version": "3.7", "latest_non_vulnerable_version": "3.7", "affected_by_vulnerabilities": [ @@ -20,6 +21,9 @@ "url": "http://public.vulnerablecode.io/api/vulnerabilities/525663", "vulnerability_id": "VCID-j3au-usaz-aaag", "summary": "Internationalized Domain Names in Applications (IDNA) vulnerable to denial of service from specially crafted inputs to idna.encode", + "exploitability": 2.0, + "weighted_severity": 4.2, + "risk_score": 8.4, "references": [ { "reference_url": "https://access.redhat.com/hydra/rest/securitydata/cve/CVE-2024-3651.json", diff --git a/vulnerabilities/tests/test_fetch.py b/vulnerabilities/tests/test_fetch.py index 77dfa5c3..47a105d8 100644 --- a/vulnerabilities/tests/test_fetch.py +++ b/vulnerabilities/tests/test_fetch.py @@ -8,6 +8,7 @@ import io import json +from decimal import Decimal from pathlib import Path from unittest import mock @@ -16,7 +17,7 @@ from component_catalog.models import Package from component_catalog.tests import make_package from dje.models import Dataspace -from vulnerabilities.fetch import fetch_for_queryset +from vulnerabilities.fetch import fetch_for_packages from vulnerabilities.fetch import fetch_from_vulnerablecode @@ -26,24 +27,26 @@ class VulnerabilitiesFetchTestCase(TestCase): def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") - @mock.patch("vulnerabilities.fetch.fetch_for_queryset") + @mock.patch("vulnerabilities.fetch.fetch_for_packages") @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.is_configured") def test_vulnerabilities_fetch_from_vulnerablecode( - self, mock_is_configured, mock_fetch_for_queryset + self, mock_is_configured, mock_fetch_for_packages ): buffer = io.StringIO() make_package(self.dataspace, package_url="pkg:pypi/idna@3.6") make_package(self.dataspace, package_url="pkg:pypi/idna@2.0") mock_is_configured.return_value = True - mock_fetch_for_queryset.return_value = 2 - fetch_from_vulnerablecode(self.dataspace, batch_size=1, timeout=None, log_func=buffer.write) + mock_fetch_for_packages.return_value = 2 + fetch_from_vulnerablecode( + self.dataspace, batch_size=1, update=True, timeout=None, log_func=buffer.write + ) expected = "2 Packages in the queue.+ Created 2 vulnerabilitiesCompleted in 0 seconds" self.assertEqual(expected, buffer.getvalue()) self.dataspace.refresh_from_db() self.assertIsNotNone(self.dataspace.vulnerabilities_updated_at) @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.bulk_search_by_purl") - def test_vulnerabilities_fetch_for_queryset(self, mock_bulk_search_by_purl): + def test_vulnerabilities_fetch_for_packages(self, mock_bulk_search_by_purl): buffer = io.StringIO() package1 = make_package(self.dataspace, package_url="pkg:pypi/idna@3.6") make_package(self.dataspace, package_url="pkg:pypi/idna@2.0") @@ -52,11 +55,27 @@ def test_vulnerabilities_fetch_for_queryset(self, mock_bulk_search_by_purl): response_json = json.loads(response_file.read_text()) mock_bulk_search_by_purl.return_value = response_json["results"] - created_vulnerabilities = fetch_for_queryset( - queryset, self.dataspace, batch_size=1, log_func=buffer.write + created_vulnerabilities = fetch_for_packages( + queryset, self.dataspace, batch_size=1, update=True, log_func=buffer.write ) self.assertEqual(1, created_vulnerabilities) self.assertEqual("Progress: 1/2Progress: 2/2", buffer.getvalue()) self.assertEqual(1, package1.affected_by_vulnerabilities.count()) vulnerability = package1.affected_by_vulnerabilities.get() self.assertEqual("VCID-j3au-usaz-aaag", vulnerability.vulnerability_id) + self.assertEqual(Decimal("2.0"), vulnerability.exploitability) + self.assertEqual(Decimal("4.2"), vulnerability.weighted_severity) + self.assertEqual(Decimal("8.4"), vulnerability.risk_score) + + package1.refresh_from_db() + self.assertEqual(Decimal("8.4"), package1.risk_score) + + # Update + response_json["results"][0]["affected_by_vulnerabilities"][0]["risk_score"] = 10.0 + mock_bulk_search_by_purl.return_value = response_json["results"] + created_vulnerabilities = fetch_for_packages( + queryset, self.dataspace, batch_size=1, update=True, log_func=buffer.write + ) + self.assertEqual(0, created_vulnerabilities) + vulnerability = package1.affected_by_vulnerabilities.get() + self.assertEqual(Decimal("10.0"), vulnerability.risk_score) diff --git a/vulnerabilities/tests/test_filters.py b/vulnerabilities/tests/test_filters.py index b10e5ad7..417ce5c2 100644 --- a/vulnerabilities/tests/test_filters.py +++ b/vulnerabilities/tests/test_filters.py @@ -16,12 +16,12 @@ class VulnerabilityFilterSetTestCase(TestCase): def setUp(self): self.dataspace = Dataspace.objects.create(name="Reference") - self.vulnerability1 = make_vulnerability(self.dataspace, max_score=10.0) + self.vulnerability1 = make_vulnerability(self.dataspace, risk_score=10.0) self.vulnerability2 = make_vulnerability( - self.dataspace, max_score=5.5, aliases=["ALIAS-V2"] + self.dataspace, risk_score=5.5, aliases=["ALIAS-V2"] ) - self.vulnerability3 = make_vulnerability(self.dataspace, max_score=2.0) - self.vulnerability4 = make_vulnerability(self.dataspace, max_score=None) + self.vulnerability3 = make_vulnerability(self.dataspace, risk_score=2.0) + self.vulnerability4 = make_vulnerability(self.dataspace, risk_score=None) def test_vulnerability_filterset_search(self): data = {"q": self.vulnerability1.vulnerability_id} @@ -33,36 +33,36 @@ def test_vulnerability_filterset_search(self): self.assertQuerySetEqual(filterset.qs, [self.vulnerability2]) def test_vulnerability_filterset_sort_nulls_last_ordering(self): - data = {"sort": "max_score"} + data = {"sort": "risk_score"} filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data) expected = [ self.vulnerability3, self.vulnerability2, self.vulnerability1, - self.vulnerability4, # The max_score=None are always last + self.vulnerability4, # The risk_score=None are always last ] self.assertQuerySetEqual(filterset.qs, expected) - data = {"sort": "-max_score"} + data = {"sort": "-risk_score"} filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data) expected = [ self.vulnerability1, self.vulnerability2, self.vulnerability3, - self.vulnerability4, # The max_score=None are always last + self.vulnerability4, # The risk_score=None are always last ] self.assertQuerySetEqual(filterset.qs, expected) - def test_vulnerability_filterset_max_score(self): - data = {"max_score": "critical"} + def test_vulnerability_filterset_risk_score(self): + data = {"risk_score": "critical"} filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data) self.assertQuerySetEqual(filterset.qs, [self.vulnerability1]) - data = {"max_score": "high"} + data = {"risk_score": "high"} filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data) self.assertQuerySetEqual(filterset.qs, []) - data = {"max_score": "medium"} + data = {"risk_score": "medium"} filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data) self.assertQuerySetEqual(filterset.qs, [self.vulnerability2]) - data = {"max_score": "low"} + data = {"risk_score": "low"} filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data) self.assertQuerySetEqual(filterset.qs, [self.vulnerability3]) diff --git a/vulnerabilities/tests/test_models.py b/vulnerabilities/tests/test_models.py index a504ab9a..05b00964 100644 --- a/vulnerabilities/tests/test_models.py +++ b/vulnerabilities/tests/test_models.py @@ -162,21 +162,8 @@ def test_vulnerability_model_create_from_data(self): self.assertEqual(vulnerability_data["aliases"], vulnerability1.aliases) self.assertEqual(vulnerability_data["references"], vulnerability1.references) self.assertEqual(vulnerability_data["resource_url"], vulnerability1.resource_url) - self.assertEqual(7.5, vulnerability1.min_score) - self.assertEqual(7.5, vulnerability1.max_score) self.assertQuerySetEqual(vulnerability1.affected_packages.all(), [package1]) - def test_vulnerability_model_create_from_data_computed_scores(self): - response_file = self.data / "vulnerabilities" / "idna_3.6_response.json" - json_data = json.loads(response_file.read_text()) - affected_by_vulnerabilities = json_data["results"][0]["affected_by_vulnerabilities"] - vulnerability1 = Vulnerability.create_from_data( - dataspace=self.dataspace, - data=affected_by_vulnerabilities[0], - ) - self.assertEqual(2.1, vulnerability1.min_score) - self.assertEqual(7.5, vulnerability1.max_score) - def test_vulnerability_model_queryset_count_methods(self): package1 = make_package(self.dataspace) package2 = make_package(self.dataspace) diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py index 7480031f..9b1b28c7 100644 --- a/vulnerabilities/views.py +++ b/vulnerabilities/views.py @@ -7,7 +7,6 @@ # from django.contrib.auth.mixins import LoginRequiredMixin -from django.db.models import F from django.http import Http404 from django.utils.translation import gettext_lazy as _ @@ -27,10 +26,10 @@ class VulnerabilityListView( template_list_table = "vulnerabilities/tables/vulnerability_list_table.html" table_headers = ( Header("vulnerability_id", _("Vulnerability")), - Header("aliases", _("Aliases")), - # Keep `max_score` to enable column sorting - Header("max_score", _("Score"), help_text="Severity score range", filter="max_score"), Header("summary", _("Summary")), + Header("exploitability", _("Exploitability"), filter="exploitability"), + Header("weighted_severity", _("Severity"), filter="weighted_severity"), + Header("risk_score", _("Risk"), filter="risk_score"), Header("affected_products_count", _("Affected products"), help_text="Affected products"), Header("affected_packages_count", _("Affected packages"), help_text="Affected packages"), Header("fixed_packages_count", _("Fixed by"), help_text="Fixed by packages"), @@ -47,18 +46,16 @@ def get_queryset(self): "aliases", "summary", "fixed_packages_count", - "max_score", - "min_score", + "exploitability", + "weighted_severity", + "risk_score", "created_date", "last_modified_date", "dataspace", ) .with_affected_products_count() .with_affected_packages_count() - .order_by( - F("max_score").desc(nulls_last=True), - "-min_score", - ) + .order_by_risk() ) def get_context_data(self, **kwargs):