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 }})
+
+
+
+
+ | {% trans "License" %} |
+ {% trans "Packages" %} |
+ {% trans "Policy" %} |
+
+
+
+ {% for license in license_distribution %}
+
+ |
+
+ {{ 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 %}
+ |
+
+ {% endfor %}
+
+
+ {% 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 "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)