diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cb2d070a..665cf2ee 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,18 @@ Release notes vulnerability data is available. https://github.com/aboutcode-org/dejacode/issues/98 +- Introduce a new VulnerabilityAnalysis model based on CycloneDX spec: + https://cyclonedx.org/docs/1.6/json/#vulnerabilities_items_analysis + A VulnerabilityAnalysis is always assigned to a Vulnerability object and a + ProductPackage relation. + The values for a VulnerabilityAnalysis are display in the Product "Vulnerabilities" + tab. + A "Edit" button can be used to open a form in a model to provided analysis data. + Those new VEX related columns can be sorted and filtered. + The VulnerabilityAnalysis data is exported in the VEX (only) and SBOM+VEX (combined) + outputs. + https://github.com/aboutcode-org/dejacode/issues/98 + ### Version 5.2.1 - Fix the models documentation navigation. diff --git a/component_catalog/filters.py b/component_catalog/filters.py index 83294143..58425811 100644 --- a/component_catalog/filters.py +++ b/component_catalog/filters.py @@ -49,6 +49,10 @@ class ComponentFilterSet(DataspacedFilterSet): "primary_language", "usage_policy", ] + dropdown_fields = [ + "type", + "usage_policy", + ] q = MatchOrderedSearchFilter( label=_("Search"), match_order_fields=["name"], @@ -122,9 +126,7 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.filters["usage_policy"].extra["to_field_name"] = "label" - self.filters["usage_policy"].extra["widget"] = DropDownRightWidget() self.filters["type"].extra["to_field_name"] = "label" - self.filters["type"].extra["widget"] = DropDownRightWidget() @cached_property def sort_value(self): @@ -180,6 +182,7 @@ def filter(self, qs, value): class PackageFilterSet(DataspacedFilterSet): + dropdown_fields = ["usage_policy"] q = PackageSearchFilter( label=_("Search"), match_order_fields=[ @@ -262,7 +265,6 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.filters["usage_policy"].extra["to_field_name"] = "label" - self.filters["usage_policy"].extra["widget"] = DropDownRightWidget() @cached_property def sort_value(self): diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index 42d1c652..ac1656a6 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -92,6 +92,7 @@ table.text-break thead { .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); @@ -390,17 +391,26 @@ table.vulnerabilities-table .column-summary { min-width: 300px; } #tab_vulnerabilities .column-exploitability { - width: 150px; + width: 155px; } #tab_vulnerabilities .column-weighted_severity { - width: 115px; + width: 120px; } #tab_vulnerabilities .column-risk_score { - width: 95px; + width: 90px; } #tab_vulnerabilities .column-summary { width: 300px; } +#tab_vulnerabilities .column-vulnerability_analyses__state { + min-width: 105px; +} +#tab_vulnerabilities .column-vulnerability_analyses__justification { + min-width: 130px; +} +#tab_vulnerabilities .column-vulnerability_analyses__responses { + min-width: 120px; +} /* -- Dependency tab -- */ #tab_dependencies .column-for_package { @@ -540,6 +550,10 @@ table.purldb-table .column-license_expression { vertical-align: middle; } +#vulnerability-analysis-form fieldset legend { + font-size: 1rem; +} + /* -- Object form (add/edit) -- */ .datepicker { width: 130px; diff --git a/dje/filters.py b/dje/filters.py index 0aab8a31..05bbd7a9 100644 --- a/dje/filters.py +++ b/dje/filters.py @@ -43,6 +43,7 @@ from dje.utils import extract_name_version from dje.utils import get_uuids_list_sorted from dje.utils import remove_field_from_query_dict +from dje.widgets import DropDownRightWidget IS_FILTER_LOOKUP_VAR = "_filter_lookup" @@ -80,6 +81,7 @@ def get_filters_breadcrumbs(self): class DataspacedFilterSet(FilterSetUtilsMixin, django_filters.FilterSet): related_only = [] + dropdown_fields = [] def __init__(self, *args, **kwargs): try: @@ -90,6 +92,7 @@ def __init__(self, *args, **kwargs): self.dynamic_qs = kwargs.pop("dynamic_qs", True) self.parent_qs_cache = {} self.anchor = kwargs.pop("anchor", None) + self.dropdown_fields = kwargs.pop("dropdown_fields", []) or self.dropdown_fields super().__init__(*args, **kwargs) @@ -106,6 +109,9 @@ def __init__(self, *args, **kwargs): model_name = self._meta.model._meta.model_name usage_policy.queryset = usage_policy.queryset.filter(content_type__model=model_name) + for field_name in self.dropdown_fields: + self.filters[field_name].extra["widget"] = DropDownRightWidget(anchor=self.anchor) + def apply_related_only(self, field_name, filter_): """ Limit the filter choices to the values used on the parent queryset. diff --git a/dje/outputs.py b/dje/outputs.py index 41be7b0a..29eefa98 100644 --- a/dje/outputs.py +++ b/dje/outputs.py @@ -132,10 +132,20 @@ def get_cyclonedx_bom(instance, user, include_components=True, include_vex=False if include_vex: vulnerability_qs = instance.get_vulnerability_qs(prefetch_related_packages=True) - vulnerabilities = [ - vulnerability.as_cyclonedx(affected_instances=vulnerability.affected_packages.all()) - for vulnerability in vulnerability_qs - ] + vulnerabilities = [] + for vulnerability in vulnerability_qs: + analysis = None + vulnerability_analyses = vulnerability.vulnerability_analyses.all() + if len(vulnerability_analyses) == 1: + analysis = vulnerability_analyses[0] + + vulnerabilities.append( + vulnerability.as_cyclonedx( + affected_instances=vulnerability.affected_packages.all(), + analysis=analysis, + ) + ) + bom.vulnerabilities = vulnerabilities return bom diff --git a/dje/templates/includes/object_list_table_header.html b/dje/templates/includes/object_list_table_header.html index f28dedf7..d6d4f5ea 100644 --- a/dje/templates/includes/object_list_table_header.html +++ b/dje/templates/includes/object_list_table_header.html @@ -36,5 +36,8 @@ {% endif %} {% endfor %} + {% if include_actions %} + + {% endif %} \ No newline at end of file diff --git a/dje/tests/test_outputs.py b/dje/tests/test_outputs.py index 93008442..1848cbd5 100644 --- a/dje/tests/test_outputs.py +++ b/dje/tests/test_outputs.py @@ -6,6 +6,8 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +import json + from django.test import TestCase from cyclonedx.model import bom as cyclonedx_bom @@ -18,6 +20,7 @@ from dje.tests import create_user from product_portfolio.models import Product from product_portfolio.tests import make_product_package +from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.tests import make_vulnerability @@ -97,7 +100,7 @@ def test_outputs_get_cyclonedx_bom(self): def test_outputs_get_cyclonedx_bom_include_vex(self): package_in_product = make_package(self.dataspace, package_url="pkg:type/name") - make_product_package(self.product1, package_in_product) + product_package1 = make_product_package(self.product1, package_in_product) package_not_in_product = make_package(self.dataspace) vulnerability1 = make_vulnerability( self.dataspace, affecting=[package_in_product, package_not_in_product] @@ -112,6 +115,24 @@ def test_outputs_get_cyclonedx_bom_include_vex(self): self.assertIsInstance(bom, cyclonedx_bom.Bom) self.assertEqual(1, len(bom.vulnerabilities)) self.assertEqual(vulnerability1.vulnerability_id, bom.vulnerabilities[0].id) + self.assertIsNone(bom.vulnerabilities[0].analysis) + + VulnerabilityAnalysis.objects.create( + product_package=product_package1, + vulnerability=vulnerability1, + state=VulnerabilityAnalysis.State.RESOLVED, + justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT, + detail="detail", + dataspace=self.dataspace, + ) + bom = outputs.get_cyclonedx_bom( + instance=self.product1, + user=self.super_user, + include_vex=True, + ) + analysis = bom.vulnerabilities[0].analysis + expected = {"detail": "detail", "justification": "code_not_present", "state": "resolved"} + self.assertEqual(expected, json.loads(analysis.as_json())) def test_outputs_get_cyclonedx_bom_json(self): bom = outputs.get_cyclonedx_bom(instance=self.product1, user=self.super_user) diff --git a/license_library/filters.py b/license_library/filters.py index e693ba54..18f50a21 100644 --- a/license_library/filters.py +++ b/license_library/filters.py @@ -29,6 +29,10 @@ class LicenseFilterSet(DataspacedFilterSet): "license_profile", "usage_policy", ] + dropdown_fields = [ + "category__license_type", + "usage_policy", + ] q = MatchOrderedSearchFilter( label=_("Search"), match_order_fields=["short_name", "key", "name"], @@ -101,6 +105,3 @@ def __init__(self, *args, **kwargs): self.filters["usage_policy"].extra["to_field_name"] = "label" self.filters["usage_policy"].label = _("Policy") self.filters["category__license_type"].label = _("Type") - - for filter_name in ["category__license_type", "usage_policy"]: - self.filters[filter_name].extra["widget"] = DropDownRightWidget() diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 54c3582e..ee8db99f 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -43,6 +43,7 @@ from dje.validators import validate_version from vulnerabilities.fetch import fetch_for_packages from vulnerabilities.models import Vulnerability +from vulnerabilities.models import VulnerabilityAnalysis RELATION_LICENSE_EXPRESSION_HELP_TEXT = _( "The License Expression assigned to a DejaCode Product Package or Product " @@ -335,6 +336,10 @@ def all_packages(self): models.Q(id__in=self.packages.all()) | models.Q(component__in=self.components.all()) ).distinct() + @cached_property + def vulnerability_count(self): + return self.get_vulnerability_qs().count() + def get_merged_descendant_ids(self): """ Return a list of Component ids collected on the Product descendants: @@ -514,13 +519,21 @@ def fetch_vulnerabilities(self): def get_vulnerability_qs(self, prefetch_related_packages=False): """Return a QuerySet of all Vulnerability instances related to this product""" - qs = Vulnerability.objects.filter(affected_packages__in=self.packages.all()) + vulnerability_qs = Vulnerability.objects.filter( + affected_packages__in=self.packages.all() + ).distinct() if prefetch_related_packages: package_qs = Package.objects.filter(product=self).only_rendering_fields() - qs = qs.prefetch_related(models.Prefetch("affected_packages", package_qs)) + analysis_qs = VulnerabilityAnalysis.objects.filter(product=self).select_related( + "package" + ) + vulnerability_qs = vulnerability_qs.prefetch_related( + models.Prefetch("affected_packages", package_qs), + models.Prefetch("vulnerability_analyses", analysis_qs), + ) - return qs + return vulnerability_qs class ProductRelationStatus(BaseStatusMixin, DataspacedModel): diff --git a/product_portfolio/templates/product_portfolio/modals/vulnerability_analysis_modal.html b/product_portfolio/templates/product_portfolio/modals/vulnerability_analysis_modal.html new file mode 100644 index 00000000..fae63967 --- /dev/null +++ b/product_portfolio/templates/product_portfolio/modals/vulnerability_analysis_modal.html @@ -0,0 +1,27 @@ +{% load crispy_forms_tags %} + \ No newline at end of file diff --git a/product_portfolio/templates/product_portfolio/product_details.html b/product_portfolio/templates/product_portfolio/product_details.html index 047b8695..c4b26359 100644 --- a/product_portfolio/templates/product_portfolio/product_details.html +++ b/product_portfolio/templates/product_portfolio/product_details.html @@ -119,6 +119,9 @@ {% if tabsets.Imports %} {% include 'product_portfolio/includes/scancode_project_status_modal.html' %} {% endif %} + {% if request.user.dataspace.enable_vulnerablecodedb_access and product.vulnerability_count %} + {% include 'product_portfolio/modals/vulnerability_analysis_modal.html' %} + {% endif %} {% endblock %} {% block extrastyle %} @@ -232,6 +235,63 @@ {% endif %} + {% if request.user.dataspace.enable_vulnerablecodedb_access and product.vulnerability_count %} + + {% endif %} + {% if purldb_enabled %}