diff --git a/.github/workflows/find-vulnerabilities.yml b/.github/workflows/find-vulnerabilities.yml index 81ce7879..d04813a1 100644 --- a/.github/workflows/find-vulnerabilities.yml +++ b/.github/workflows/find-vulnerabilities.yml @@ -23,7 +23,7 @@ jobs: persist-credentials: false # do not keep the token around - name: Fail on known vulnerabilities - uses: aboutcode-org/scancode-action@8adbf888f487c3cdf6c15386035769cd03a94c66 + uses: aboutcode-org/scancode-action@6e900c920928c44932e756e308561451b09ec58b with: pipelines: "inspect_packages:StaticResolver,find_vulnerabilities" check-compliance: true diff --git a/component_catalog/api.py b/component_catalog/api.py index e00c7a5b..abd8705f 100644 --- a/component_catalog/api.py +++ b/component_catalog/api.py @@ -58,8 +58,8 @@ from license_library.models import License from organization.api import OwnerEmbeddedSerializer from vulnerabilities.api import VulnerabilitySerializer -from vulnerabilities.filters import RISK_SCORE_RANGES from vulnerabilities.filters import ScoreRangeFilter +from vulnerabilities.models import RISK_SCORE_RANGES class LicenseSummaryMixin: diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index d71dfd80..91ba8de9 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -43,6 +43,9 @@ a.dropdown-item:hover { .fs-110pct { font-size: 110%; } +.fs-xs { + font-size: .75rem; +} .header { margin-bottom: 1rem; } @@ -91,6 +94,10 @@ table.text-break thead { } .bg-warning-orange { background-color: var(--bs-orange); + color: #000; +} +.text-warning-orange { + color: var(--bs-orange) !important; } .spinner-border-md { --bs-spinner-width: 1.5rem; diff --git a/dje/tests/test_permissions.py b/dje/tests/test_permissions.py index 0c11f9a0..da1b5978 100644 --- a/dje/tests/test_permissions.py +++ b/dje/tests/test_permissions.py @@ -133,6 +133,7 @@ def test_permissions_get_all_tabsets(self): ], "product": [ "essentials", + "compliance", "inventory", "codebase", "hierarchy", diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index 8b13f182..ebcf8fde 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -22,6 +22,7 @@ from dje.filters import DataspacedFilterSet from dje.filters import DefaultOrderingFilter from dje.filters import HasRelationFilter +from dje.filters import HasValueFilter from dje.filters import MatchOrderedSearchFilter from dje.filters import SearchFilter from dje.widgets import BootstrapSelectMultipleWidget @@ -34,8 +35,8 @@ 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 RISK_SCORE_RANGES from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysisMixin @@ -129,6 +130,26 @@ class Meta: ] +class HasComplianceIssueFilter(django_filters.BooleanFilter): + """Filter objects that have a compliance alert (warning or error) on their usage policy.""" + + def __init__(self, *args, **kwargs): + kwargs.setdefault("label", _("Compliance issues")) + kwargs.setdefault("field_name", "compliance_alert") + super().__init__(*args, **kwargs) + + def filter(self, qs, value): + if value is None: + return qs + lookup = {f"{self.field_name}__in": ["warning", "error"]} + if value: + qs = qs.filter(**lookup) + else: + qs = qs.exclude(**lookup) + + return qs.distinct() if self.distinct else qs + + class BaseProductRelationFilterSet(DataspacedFilterSet): field_name_prefix = None dropdown_fields = [ @@ -148,6 +169,7 @@ class BaseProductRelationFilterSet(DataspacedFilterSet): ) is_modified = BooleanChoiceFilter() object_type = django_filters.CharFilter( + label=_("Item type"), method="filter_object_type", widget=DropDownWidget( anchor="#inventory", @@ -171,6 +193,21 @@ class BaseProductRelationFilterSet(DataspacedFilterSet): label=_("Risk score"), score_ranges=RISK_SCORE_RANGES, ) + licenses = django_filters.ModelMultipleChoiceFilter( + label=_("License"), + field_name="licenses__key", + to_field_name="key", + queryset=License.objects.only("key", "short_name", "dataspace__id"), + ) + has_licenses = HasValueFilter( + label=_("Has licenses"), + field_name="license_expression", + ) + license_compliance_issues = HasComplianceIssueFilter( + label=_("License compliance issues"), + field_name="licenses__usage_policy__compliance_alert", + distinct=True, + ) @staticmethod def filter_object_type(queryset, name, value): @@ -237,6 +274,10 @@ class ProductComponentFilterSet(BaseProductRelationFilterSet): anchor="#inventory", right_align=True, link_content='' ), ) + compliance_issues = HasComplianceIssueFilter( + field_name="component__usage_policy__compliance_alert", + distinct=True, + ) class Meta: model = ProductComponent @@ -307,6 +348,10 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet): ("unknown", _("Reachability not known")), ), ) + compliance_issues = HasComplianceIssueFilter( + field_name="package__usage_policy__compliance_alert", + distinct=True, + ) class Meta: model = ProductPackage diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html new file mode 100644 index 00000000..7ae4f2e9 --- /dev/null +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -0,0 +1,149 @@ +{% load i18n %} +
+ {# License panel #} +
+
+
+

{% trans "License compliance" %}

+ {% if license_issues_count == 0 %} + + {% trans "OK" %} + + {% elif license_error_count > 0 %} + + {% trans "Error" %} + + {% else %} + + {% trans "Warning" %} + + {% endif %} +
+ +

+ {% trans "License distribution" %} (top {{ license_distribution_limit }}) +

+ + + + + + + + + + {% for license in license_distribution %} + + + + + + {% endfor %} + +
{% trans "License" %}{% trans "Packages" %}{% trans "Policy" %}
+ + {{ license.spdx_license_key }} + + + + {{ license.package_count }} + + + {% if license.compliance_alert == "error" %} + {% trans "Error" %} + {% elif license.compliance_alert == "warning" %} + {% trans "Warning" %} + {% else %} + {% trans "OK" %} + {% endif %} +
+ {% if remaining_license_count > 0 %} +
+ + + {{ remaining_license_count }} {% trans "other license" %}{{ remaining_license_count|pluralize }}{% if license_issues_count == 0 %}, {% trans "all within policy" %}{% endif %} + +
+ {% endif %} +
+
+ + {# Security panel #} +
+
+ {# Header #} +
+

{% trans "Security compliance" %}

+ {% if vulnerability_count == 0 or above_threshold_count == 0 %} + {% trans "OK" %} + {% elif max_vulnerability_severity == "critical" %} + {% trans "Critical" %} + {% elif max_vulnerability_severity == "high" %} + {% trans "High" %} + {% elif max_vulnerability_severity == "medium" %} + {% trans "Medium" %} + {% else %} + {% trans "Low" %} + {% endif %} +
+ + {# Summary #} + {% if vulnerability_count > 0 %} +

+ {% if risk_threshold_number %} + {% if above_threshold_count > 0 %} + {{ above_threshold_count }} {% trans "of" %} {{ vulnerability_count }} + {% trans "vulnerabilit" %}{{ vulnerability_count|pluralize:"y,ies" }} + {% trans "above risk threshold of" %} {{ risk_threshold_number }} + {% else %} + {{ vulnerability_count }} + {% trans "vulnerabilit" %}{{ vulnerability_count|pluralize:"y,ies" }}, + {% trans "all below risk threshold of" %} {{ risk_threshold_number }} + {% endif %} + {% else %} + {{ vulnerability_count }} + {% trans "vulnerabilit" %}{{ vulnerability_count|pluralize:"y,ies" }} + — {% trans "no risk threshold set" %} + {% endif %} +

+ {% endif %} + + {# Vulnerability list #} + {% for vulnerability in vulnerabilities %} +
+ + {% if vulnerability.risk_level == "critical" %} + {% trans "Critical" %} + {% elif vulnerability.risk_level == "high" %} + {% trans "High" %} + {% elif vulnerability.risk_level == "medium" %} + {% trans "Medium" %} + {% elif vulnerability.risk_level == "low" %} + {% trans "Low" %} + {% else %} + {% trans "Unknown" %} + {% endif %} + + + {{ vulnerability.vulnerability_id }} + + {{ vulnerability.summary|truncatechars:70 }} +
+ {% empty %} +
+ + {% trans "No known vulnerabilities" %} +
+ {% endfor %} + + {# View all link #} + {% if vulnerabilities|length < vulnerability_count %} + + {% endif %} +
+
+
\ No newline at end of file diff --git a/product_portfolio/templates/product_portfolio/compliance/metric_cards.html b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html new file mode 100644 index 00000000..05a4ecba --- /dev/null +++ b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html @@ -0,0 +1,78 @@ +{% load i18n humanize %} +
+
+
+
{% trans "Total packages" %}
+
{{ total_packages|intcomma }}
+
+ {% if package_issues_count == 0 %} + {% trans "No policy violations" %} + {% else %} + + {{ package_issues_count }} {% trans "package policy violation" %}{{ package_issues_count|pluralize }} + + {% endif %} +
+
+
+
+
+
{% trans "License compliance" %}
+
+ {{ license_compliance_pct }}% +
+
+ {% if packages_with_license_issues == 0 %} + {% trans "All packages within policy" %} + {% else %} + + {{ packages_with_license_issues }} {% trans "packages with license policy violations" %} + + {% endif %} +
+
+
+
+
+
{% trans "License coverage" %}
+
+ {{ license_coverage_pct }}% +
+
+ {% if license_coverage_pct == 100 %} + {% trans "All packages have a license" %} + {% else %} + + {{ package_without_license_count }} {% trans "packages without license" %} + + {% endif %} +
+
+
+
+
+
{% trans "Vulnerabilities" %}
+ {% if vulnerability_count == 0 %} +
0
+
{% trans "No known vulnerabilities" %}
+ {% elif risk_threshold_number and above_threshold_count == 0 %} +
0
+
+ {{ vulnerability_count }} {% trans "below risk threshold of" %} {{ risk_threshold_number }} +
+ {% elif risk_threshold_number %} +
{{ above_threshold_count }}
+
+ {% trans "of" %} {{ vulnerability_count }} {% trans "above threshold of" %} {{ risk_threshold_number }} +
+ {% else %} +
+ {{ vulnerability_count }} +
+
+ {% if critical_count %}{{ critical_count }} {% trans "critical" %}{% endif %}{% if critical_count and high_count %}, {% endif %}{% if high_count %}{{ high_count }} {% trans "high" %}{% endif %}{% if medium_count and critical_count or medium_count and high_count %}, {% endif %}{% if medium_count %}{{ medium_count }} {% trans "medium" %}{% endif %}{% if low_count and vulnerability_count == low_count %}{{ low_count }} {% trans "low" %}{% endif %} +
+ {% endif %} +
+
+
\ No newline at end of file diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_compliance.html b/product_portfolio/templates/product_portfolio/tabs/tab_compliance.html new file mode 100644 index 00000000..202ca093 --- /dev/null +++ b/product_portfolio/templates/product_portfolio/tabs/tab_compliance.html @@ -0,0 +1,4 @@ +{% spaceless %} + {% include 'product_portfolio/compliance/metric_cards.html' %} + {% include 'product_portfolio/compliance/compliance_panels.html' %} +{% endspaceless %} \ No newline at end of file diff --git a/product_portfolio/tests/test_filters.py b/product_portfolio/tests/test_filters.py new file mode 100644 index 00000000..ce167547 --- /dev/null +++ b/product_portfolio/tests/test_filters.py @@ -0,0 +1,83 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from django.test import TestCase + +from component_catalog.tests import make_package +from dje.models import Dataspace +from license_library.models import License +from organization.models import Owner +from product_portfolio.filters import ProductPackageFilterSet +from product_portfolio.models import ProductPackage +from product_portfolio.tests import make_product + + +class ProductPackageFilterSetTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="Reference") + self.owner = Owner.objects.create(name="Owner1", dataspace=self.dataspace) + self.license1 = License.objects.create( + key="mit", + name="MIT", + short_name="MIT", + owner=self.owner, + dataspace=self.dataspace, + ) + self.license2 = License.objects.create( + key="apache-2.0", + name="Apache 2.0", + short_name="Apache 2.0", + owner=self.owner, + dataspace=self.dataspace, + ) + + self.product = make_product(self.dataspace) + self.package1 = make_package(self.dataspace) + self.package2 = make_package(self.dataspace) + self.package3 = make_package(self.dataspace) + + self.pp1 = ProductPackage.objects.create( + product=self.product, + package=self.package1, + license_expression=self.license1.key, + dataspace=self.dataspace, + ) + self.pp2 = ProductPackage.objects.create( + product=self.product, + package=self.package2, + license_expression=self.license2.key, + dataspace=self.dataspace, + ) + self.pp3 = ProductPackage.objects.create( + product=self.product, + package=self.package3, + license_expression="", + dataspace=self.dataspace, + ) + + def test_product_package_filterset_licenses(self): + data = {"licenses": [self.license1.key]} + filterset = ProductPackageFilterSet(dataspace=self.dataspace, data=data) + self.assertQuerySetEqual(filterset.qs, [self.pp1]) + + data = {"licenses": [self.license2.key]} + filterset = ProductPackageFilterSet(dataspace=self.dataspace, data=data) + self.assertQuerySetEqual(filterset.qs, [self.pp2]) + + data = {"licenses": [self.license1.key, self.license2.key]} + filterset = ProductPackageFilterSet(dataspace=self.dataspace, data=data) + self.assertQuerySetEqual(filterset.qs, [self.pp1, self.pp2], ordered=False) + + def test_product_package_filterset_has_licenses(self): + data = {"has_licenses": "yes"} + filterset = ProductPackageFilterSet(dataspace=self.dataspace, data=data) + self.assertQuerySetEqual(filterset.qs, [self.pp1, self.pp2], ordered=False) + + data = {"has_licenses": "no"} + filterset = ProductPackageFilterSet(dataspace=self.dataspace, data=data) + self.assertQuerySetEqual(filterset.qs, [self.pp3]) diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 4dc5b849..878775a7 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -134,7 +134,7 @@ def test_product_portfolio_detail_view_tab_inventory_and_hierarchy_availability( ProductComponent.objects.create( product=self.product1, component=self.component1, dataspace=self.dataspace ) - with self.assertNumQueries(30): + with self.assertNumQueries(29): response = self.client.get(url) self.assertContains(response, expected1) self.assertContains(response, expected2) @@ -464,6 +464,7 @@ def test_product_portfolio_detail_view_tab_vulnerability_label(self, mock_is_con self.dataspace.enable_vulnerablecodedb_access = True self.dataspace.save() + make_product_package(self.product1) response = self.client.get(url) expected = 'aria-controls="tab_vulnerabilities" aria-selected="false" disabled="disabled"' self.assertContains(response, expected) @@ -3416,3 +3417,151 @@ def test_product_portfolio_vulnerability_analysis_form_view(self): self.assertEqual(product_package, analysis.product_package) self.assertEqual(vulnerability1, analysis.vulnerability) self.assertEqual("resolved", analysis.state) + + def test_product_portfolio_tab_compliance_view_empty(self): + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_url("tab_compliance") + response = self.client.get(url) + self.assertEqual(200, response.status_code) + self.assertEqual(0, response.context["total_packages"]) + self.assertEqual(100, response.context["license_compliance_pct"]) + self.assertEqual(100, response.context["license_coverage_pct"]) + self.assertEqual(0, response.context["vulnerability_count"]) + + def test_product_portfolio_tab_compliance_view_package_compliance(self): + self.client.login(username="nexb_user", password="secret") + + owner1 = Owner.objects.create(name="Owner1", dataspace=self.dataspace) + license1 = License.objects.create( + key="l1", name="L1", short_name="L1", owner=owner1, dataspace=self.dataspace + ) + package_policy = UsagePolicy.objects.create( + label="PackagePolicy", + icon="icon", + content_type=ContentType.objects.get_for_model(Package), + compliance_alert=UsagePolicy.Compliance.ERROR, + dataspace=self.dataspace, + ) + + package2 = make_package(self.dataspace, usage_policy=package_policy) + package3 = make_package(self.dataspace) + + # package2 has a policy issue, package3 does not + ProductPackage.objects.create( + product=self.product1, + package=package2, + dataspace=self.dataspace, + license_expression=license1.key, + ) + ProductPackage.objects.create( + product=self.product1, + package=package3, + dataspace=self.dataspace, + ) + + url = self.product1.get_url("tab_compliance") + response = self.client.get(url) + self.assertEqual(2, response.context["total_packages"]) + self.assertEqual(1, response.context["package_issues_count"]) + # One package without license expression + self.assertEqual(1, response.context["package_without_license_count"]) + self.assertEqual(50, response.context["license_coverage_pct"]) + + def test_product_portfolio_tab_compliance_view_license_compliance(self): + self.client.login(username="nexb_user", password="secret") + + owner1 = Owner.objects.create(name="Owner1", dataspace=self.dataspace) + license_policy_error = UsagePolicy.objects.create( + label="LicensePolicyError", + icon="icon", + content_type=ContentType.objects.get_for_model(License), + compliance_alert=UsagePolicy.Compliance.ERROR, + dataspace=self.dataspace, + ) + license_policy_warning = UsagePolicy.objects.create( + label="LicensePolicyWarning", + icon="icon", + content_type=ContentType.objects.get_for_model(License), + compliance_alert=UsagePolicy.Compliance.WARNING, + dataspace=self.dataspace, + ) + license1 = License.objects.create( + key="l1", + name="L1", + short_name="L1", + owner=owner1, + usage_policy=license_policy_error, + dataspace=self.dataspace, + ) + license2 = License.objects.create( + key="l2", + name="L2", + short_name="L2", + owner=owner1, + usage_policy=license_policy_warning, + dataspace=self.dataspace, + ) + + package2 = make_package(self.dataspace) + package3 = make_package(self.dataspace) + ProductPackage.objects.create( + product=self.product1, + package=package2, + dataspace=self.dataspace, + license_expression=license1.key, + ) + ProductPackage.objects.create( + product=self.product1, + package=package3, + dataspace=self.dataspace, + license_expression=license2.key, + ) + + url = self.product1.get_url("tab_compliance") + response = self.client.get(url) + self.assertEqual(2, response.context["license_issues_count"]) + self.assertEqual(1, response.context["license_error_count"]) + self.assertEqual(1, response.context["license_warning_count"]) + # Both packages have license issues, so 0% compliance + self.assertEqual(0, response.context["license_compliance_pct"]) + + def test_product_portfolio_tab_compliance_view_security_compliance(self): + self.client.login(username="nexb_user", password="secret") + + p1 = make_package(self.dataspace) + p2 = make_package(self.dataspace) + p3 = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=[p1], risk_score=9.0) + make_vulnerability(self.dataspace, affecting=[p2], risk_score=6.5) + make_vulnerability(self.dataspace, affecting=[p3], risk_score=2.0) + + product1 = make_product(self.dataspace, inventory=[p1, p2, p3]) + url = product1.get_url("tab_compliance") + response = self.client.get(url) + + self.assertEqual(3, response.context["vulnerability_count"]) + self.assertEqual(3, response.context["above_threshold_count"]) + self.assertEqual("critical", response.context["max_vulnerability_severity"]) + self.assertEqual(1, response.context["critical_count"]) + self.assertEqual(1, response.context["high_count"]) + self.assertEqual(0, response.context["medium_count"]) + self.assertEqual(1, response.context["low_count"]) + + def test_product_portfolio_tab_compliance_view_security_with_risk_threshold(self): + self.client.login(username="nexb_user", password="secret") + + p1 = make_package(self.dataspace) + p2 = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=[p1], risk_score=9.0) + make_vulnerability(self.dataspace, affecting=[p2], risk_score=2.0) + + product1 = make_product(self.dataspace, inventory=[p1, p2]) + product1.update(vulnerabilities_risk_threshold=6.0) + + url = product1.get_url("tab_compliance") + response = self.client.get(url) + + self.assertEqual(2, response.context["vulnerability_count"]) + self.assertEqual(1, response.context["above_threshold_count"]) + self.assertEqual("high", response.context["risk_threshold"]) + self.assertEqual(6.0, response.context["risk_threshold_number"]) diff --git a/product_portfolio/urls.py b/product_portfolio/urls.py index d7267d47..c818557a 100644 --- a/product_portfolio/urls.py +++ b/product_portfolio/urls.py @@ -23,6 +23,7 @@ from product_portfolio.views import ProductListView from product_portfolio.views import ProductSendAboutFilesView from product_portfolio.views import ProductTabCodebaseView +from product_portfolio.views import ProductTabComplianceView from product_portfolio.views import ProductTabDependenciesView from product_portfolio.views import ProductTabImportsView from product_portfolio.views import ProductTabInventoryView @@ -123,6 +124,7 @@ def product_path(path_segment, view): *product_path("tab_vulnerabilities", ProductTabVulnerabilitiesView.as_view()), *product_path("tab_imports", ProductTabImportsView.as_view()), *product_path("tab_inventory", ProductTabInventoryView.as_view()), + *product_path("tab_compliance", ProductTabComplianceView.as_view()), *product_path("pull_project_data", PullProjectDataFromScanCodeIOView.as_view()), path( "///", diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 01403eb2..f223ca34 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -26,8 +26,10 @@ from django.db import transaction from django.db.models import Count from django.db.models import Exists +from django.db.models import F from django.db.models import OuterRef from django.db.models import Prefetch +from django.db.models import Q from django.db.models import Subquery from django.db.models.functions import Lower from django.forms import modelformset_factory @@ -143,6 +145,7 @@ from vulnerabilities.models import AffectedByVulnerabilityMixin from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysis +from vulnerabilities.models import get_risk_level class BaseProductViewMixin: @@ -274,6 +277,11 @@ class ProductDetailsView( "dataspace", ], }, + "compliance": { + "fields": [ + "packages", + ], + }, "inventory": { "fields": [ "components", @@ -494,6 +502,27 @@ def tab_hierarchy(self): return {"fields": [(None, context, None, template)]} + def tab_compliance(self): + if not self.has_packages: + return + + template = "tabs/tab_async_loader.html" + + # Pass the current request query context to the async request + tab_view_url = self.object.get_url("tab_compliance") + if full_query_string := self.request.META["QUERY_STRING"]: + tab_view_url += f"?{full_query_string}" + + tab_context = { + "tab_view_url": tab_view_url, + "tab_object_name": "compliance", + } + + return { + "label": "Compliance", + "fields": [(None, tab_context, None, template)], + } + def tab_inventory(self): productcomponents_count = self.object.productcomponents.count() productpackages_count = self.object.productpackages.count() @@ -543,6 +572,9 @@ def tab_dependencies(self): } def tab_vulnerabilities(self): + if not self.has_packages: + return + product = self.object dataspace = product.dataspace vulnerablecode = VulnerableCode(dataspace) @@ -653,6 +685,7 @@ def get_context_data(self, **kwargs): product = self.object user = self.request.user dataspace = user.dataspace + self.has_packages = self.object.productpackages.exists() # This behavior does not works well in the context of getting informed about # tasks completion on the Product. @@ -2651,3 +2684,133 @@ def delete_scan_htmx_view(request, project_uuid, package_uuid): "user": request.user, } return render(request, template, context) + + +class ProductTabComplianceView( + LoginRequiredMixin, + BaseProductViewMixin, + TabContentView, +): + template_name = "product_portfolio/tabs/tab_compliance.html" + tab_id = "compliance" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + product = self.object + productpackages = product.productpackages.all() + licenses = License.objects.filter(productpackage__in=productpackages) + + context.update( + { + **self.get_package_compliance_context(productpackages), + **self.get_license_compliance_context(licenses), + **self.get_security_compliance_context(product), + } + ) + + return context + + @staticmethod + def get_package_compliance_context(productpackages): + # "Total packages" card: alert at the Package level. + total_packages = productpackages.count() + package_issues_count = productpackages.filter( + package__usage_policy__compliance_alert__in=["warning", "error"] + ).count() + + # "License compliance" card: alert at the ProductPackage license level. + packages_with_license_issues = ( + productpackages.filter( + licenses__usage_policy__compliance_alert__in=["warning", "error"] + ) + .distinct() + .count() + ) + license_compliance_pct = ( + round(((total_packages - packages_with_license_issues) / total_packages) * 100) + if total_packages + else 100 + ) + + # "License coverage" card: missing license at the ProductPackage level. + package_without_license_count = productpackages.filter(license_expression="").count() + license_coverage_pct = ( + round(((total_packages - package_without_license_count) / total_packages) * 100) + if total_packages + else 100 + ) + + return { + "total_packages": total_packages, + "package_issues_count": package_issues_count, + "packages_with_license_issues": packages_with_license_issues, + "license_compliance_pct": license_compliance_pct, + "license_coverage_pct": license_coverage_pct, + "package_without_license_count": package_without_license_count, + } + + @staticmethod + def get_license_compliance_context(licenses, distribution_limit=10): + license_distribution = list( + licenses.values("key", "short_name", "spdx_license_key") + .annotate( + package_count=Count("productpackage"), + compliance_alert=F("usage_policy__compliance_alert"), + ) + .order_by("-package_count") + ) + license_error_count = sum( + 1 for entry in license_distribution if entry["compliance_alert"] == "error" + ) + license_warning_count = sum( + 1 for entry in license_distribution if entry["compliance_alert"] == "warning" + ) + + return { + "license_issues_count": license_error_count + license_warning_count, + "license_error_count": license_error_count, + "license_warning_count": license_warning_count, + "license_distribution": license_distribution[:distribution_limit], + "license_distribution_limit": distribution_limit, + "remaining_license_count": max(0, len(license_distribution) - distribution_limit), + } + + @staticmethod + def get_security_compliance_context(product, display_limit=10): + risk_threshold = product.get_vulnerabilities_risk_threshold() + risk_threshold_label = get_risk_level(risk_threshold) + + all_vulnerabilities = product.get_vulnerability_qs(risk_threshold=None) + vulnerability_count = all_vulnerabilities.count() + + if risk_threshold is not None: + above_threshold_count = all_vulnerabilities.filter( + risk_score__gte=risk_threshold + ).count() + else: + above_threshold_count = vulnerability_count + + all_vulnerabilities_ordered = all_vulnerabilities.order_by_risk() + + max_vulnerability_severity = None + first = all_vulnerabilities_ordered.first() + if first: + max_vulnerability_severity = first.risk_level + + severity_counts = all_vulnerabilities.aggregate( + critical_count=Count("id", filter=Q(risk_level="critical")), + high_count=Count("id", filter=Q(risk_level="high")), + medium_count=Count("id", filter=Q(risk_level="medium")), + low_count=Count("id", filter=Q(risk_level="low")), + ) + + return { + "risk_threshold_number": risk_threshold, + "risk_threshold": risk_threshold_label, + "max_vulnerability_severity": max_vulnerability_severity, + "vulnerability_count": vulnerability_count, + "above_threshold_count": above_threshold_count, + "vulnerabilities": all_vulnerabilities_ordered[:display_limit], + **severity_counts, + } diff --git a/vulnerabilities/api.py b/vulnerabilities/api.py index 090a2f00..b1c62f9e 100644 --- a/vulnerabilities/api.py +++ b/vulnerabilities/api.py @@ -20,8 +20,8 @@ from dje.api_custom import TabPermission from dje.filters import LastModifiedDateFilter from dje.filters import MultipleUUIDFilter -from vulnerabilities.filters import RISK_SCORE_RANGES from vulnerabilities.filters import ScoreRangeFilter +from vulnerabilities.models import RISK_SCORE_RANGES from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysis diff --git a/vulnerabilities/filters.py b/vulnerabilities/filters.py index dd9dcc31..e63a20a2 100644 --- a/vulnerabilities/filters.py +++ b/vulnerabilities/filters.py @@ -16,16 +16,10 @@ from dje.filters import SearchFilter from dje.widgets import DropDownRightWidget from dje.widgets import SortDropDownWidget +from vulnerabilities.models import RISK_SCORE_RANGES from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysisMixin -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): """ diff --git a/vulnerabilities/migrations/0006_vulnerability_risk_level_and_more.py b/vulnerabilities/migrations/0006_vulnerability_risk_level_and_more.py new file mode 100644 index 00000000..510b2601 --- /dev/null +++ b/vulnerabilities/migrations/0006_vulnerability_risk_level_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-03-25 12:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dje', '0014_apitoken_data'), + ('vulnerabilities', '0005_vulnerabilityanalysis_is_reachable_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='vulnerability', + name='risk_level', + field=models.GeneratedField(db_persist=True, expression=models.Case(models.When(risk_score__gte=8.0, then=models.Value('critical')), models.When(risk_score__gte=6.0, then=models.Value('high')), models.When(risk_score__gte=3.0, then=models.Value('medium')), models.When(risk_score__gte=0.1, then=models.Value('low')), default=models.Value(''), output_field=models.CharField(max_length=8)), output_field=models.CharField(max_length=8)), + ), + migrations.AddIndex( + model_name='vulnerability', + index=models.Index(fields=['risk_level'], name='vulnerabili_risk_le_a21c6a_idx'), + ), + ] diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 4baeefc8..7bf65d2f 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -11,7 +11,12 @@ from django.contrib.postgres.fields import ArrayField from django.db import models +from django.db.models import Case +from django.db.models import CharField from django.db.models import Count +from django.db.models import GeneratedField +from django.db.models import Value +from django.db.models import When from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -27,6 +32,24 @@ logger = logging.getLogger("dje") +RISK_SCORE_RANGES = { + "low": (0.1, 2.9), + "medium": (3.0, 5.9), + "high": (6.0, 7.9), + "critical": (8.0, 10.0), +} + + +def get_risk_level(score): + """Return the risk severity level for a given risk score.""" + if score is None: + return "" + score = float(score) + for label, (low, high) in RISK_SCORE_RANGES.items(): + if low <= score <= high: + return label + return "" + class VulnerabilityQuerySet(DataspacedQuerySet): def with_affected_products_count(self): @@ -148,6 +171,18 @@ class Vulnerability(HistoryDateFieldsMixin, DataspacedModel): "exploitability, capped at 10." ), ) + risk_level = GeneratedField( + expression=Case( + When(risk_score__gte=8.0, then=Value("critical")), + When(risk_score__gte=6.0, then=Value("high")), + When(risk_score__gte=3.0, then=Value("medium")), + When(risk_score__gte=0.1, then=Value("low")), + default=Value(""), + output_field=CharField(max_length=8), + ), + output_field=CharField(max_length=8), + db_persist=True, + ) objects = DataspacedManager.from_queryset(VulnerabilityQuerySet)() @@ -156,6 +191,7 @@ class Meta: unique_together = (("dataspace", "vulnerability_id"), ("dataspace", "uuid")) indexes = [ models.Index(fields=["vulnerability_id"]), + models.Index(fields=["risk_level"]), ] def __str__(self): diff --git a/vulnerabilities/tests/test_models.py b/vulnerabilities/tests/test_models.py index 729f4ae4..e5e31159 100644 --- a/vulnerabilities/tests/test_models.py +++ b/vulnerabilities/tests/test_models.py @@ -24,6 +24,7 @@ from product_portfolio.tests import make_product_package from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysis +from vulnerabilities.models import get_risk_level from vulnerabilities.tests import make_vulnerability from vulnerabilities.tests import make_vulnerability_analysis @@ -369,3 +370,66 @@ def test_vulnerability_model_vulnerability_propagate(self): analysis1.update(state=VulnerabilityAnalysis.State.EXPLOITABLE) new_analysis = analysis1.propagate(product2.uuid, self.super_user) self.assertEqual(VulnerabilityAnalysis.State.EXPLOITABLE, new_analysis.state) + + def test_vulnerability_model_get_risk_level(self): + self.assertEqual("", get_risk_level(None)) + self.assertEqual("", get_risk_level(0.0)) + self.assertEqual("low", get_risk_level(0.1)) + self.assertEqual("low", get_risk_level(2.9)) + self.assertEqual("medium", get_risk_level(3.0)) + self.assertEqual("medium", get_risk_level(5.9)) + self.assertEqual("high", get_risk_level(6.0)) + self.assertEqual("high", get_risk_level(7.9)) + self.assertEqual("critical", get_risk_level(8.0)) + self.assertEqual("critical", get_risk_level(10.0)) + self.assertEqual("", get_risk_level(10.1)) + self.assertEqual("high", get_risk_level("7.5")) + + def test_vulnerability_model_risk_level_generated_field(self): + vulnerability1 = make_vulnerability(dataspace=self.dataspace, risk_score=None) + self.assertEqual("", vulnerability1.risk_level) + + vulnerability1.risk_score = 0.0 + vulnerability1.save() + vulnerability1.refresh_from_db() + self.assertEqual("", vulnerability1.risk_level) + + vulnerability1.risk_score = 0.1 + vulnerability1.save() + vulnerability1.refresh_from_db() + self.assertEqual("low", vulnerability1.risk_level) + + vulnerability1.risk_score = 2.9 + vulnerability1.save() + vulnerability1.refresh_from_db() + self.assertEqual("low", vulnerability1.risk_level) + + vulnerability1.risk_score = 3.0 + vulnerability1.save() + vulnerability1.refresh_from_db() + self.assertEqual("medium", vulnerability1.risk_level) + + vulnerability1.risk_score = 5.9 + vulnerability1.save() + vulnerability1.refresh_from_db() + self.assertEqual("medium", vulnerability1.risk_level) + + vulnerability1.risk_score = 6.0 + vulnerability1.save() + vulnerability1.refresh_from_db() + self.assertEqual("high", vulnerability1.risk_level) + + vulnerability1.risk_score = 7.9 + vulnerability1.save() + vulnerability1.refresh_from_db() + self.assertEqual("high", vulnerability1.risk_level) + + vulnerability1.risk_score = 8.0 + vulnerability1.save() + vulnerability1.refresh_from_db() + self.assertEqual("critical", vulnerability1.risk_level) + + vulnerability1.risk_score = 10.0 + vulnerability1.save() + vulnerability1.refresh_from_db() + self.assertEqual("critical", vulnerability1.risk_level)