diff --git a/component_catalog/migrations/0014_add_detected_date_to_affected_by_vulnerability.py b/component_catalog/migrations/0014_add_detected_date_to_affected_by_vulnerability.py new file mode 100644 index 00000000..a790c3f6 --- /dev/null +++ b/component_catalog/migrations/0014_add_detected_date_to_affected_by_vulnerability.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0 on 2026-07-20 + +import django.utils.timezone +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("component_catalog", "0013_package_package_content"), + ] + + operations = [ + migrations.AddField( + model_name="componentaffectedbyvulnerability", + name="detected_date", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + help_text=( + "Date and time when this vulnerability was first detected on this object. " + "Used to measure how long a vulnerability has remained unaddressed. " + "Defaults to the time the record was created." + ), + ), + preserve_default=False, + ), + migrations.AddField( + model_name="packageaffectedbyvulnerability", + name="detected_date", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + help_text=( + "Date and time when this vulnerability was first detected on this object. " + "Used to measure how long a vulnerability has remained unaddressed. " + "Defaults to the time the record was created." + ), + ), + preserve_default=False, + ), + ] diff --git a/component_catalog/tests/test_models.py b/component_catalog/tests/test_models.py index ecb9fdc5..195062b5 100644 --- a/component_catalog/tests/test_models.py +++ b/component_catalog/tests/test_models.py @@ -36,6 +36,7 @@ from component_catalog.models import ComponentType from component_catalog.models import LicenseExpressionMixin from component_catalog.models import Package +from component_catalog.models import PackageAffectedByVulnerability from component_catalog.models import PackageAlreadyExistsWarning from component_catalog.models import Subcomponent from component_catalog.tests import make_package @@ -1372,9 +1373,15 @@ def test_component_catalog_models_get_exclude_candidates_fields(self): ) for model_class, expected in input_data: - results = [f.name for f in model_class().get_exclude_candidates_fields()] + results = [field.name for field in model_class().get_exclude_candidates_fields()] self.assertEqual(sorted(expected), sorted(results)) + def test_package_affected_by_vulnerability_excludes_auto_now_add_fields(self): + field_names = [ + field.name for field in PackageAffectedByVulnerability().get_exclude_candidates_fields() + ] + self.assertNotIn("detected_date", field_names) + def test_component_create_with_or_and_and_in_license_name_and_key(self): or_license = License.objects.create( key="orrible", diff --git a/dje/models.py b/dje/models.py index 996195ce..259a980b 100644 --- a/dje/models.py +++ b/dje/models.py @@ -1143,6 +1143,7 @@ def get_exclude_candidates_fields(self): field.related_model is Dataspace, isinstance(field, models.AutoField), isinstance(field, models.UUIDField), + getattr(field, "auto_now_add", False) or getattr(field, "auto_now", False), not field.null and not field.blank and not field.has_default(), field.name in ALWAYS_EXCLUDE, ] diff --git a/policy/rules.py b/policy/rules.py index bc89931c..e75498fa 100644 --- a/policy/rules.py +++ b/policy/rules.py @@ -6,7 +6,15 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +from datetime import timedelta + from django.apps import apps +from django.db.models import Exists +from django.db.models import OuterRef +from django.utils import timezone + +# VulnerabilityAnalysis states that indicate a vulnerability has been triaged and addressed. +TERMINAL_VULNERABILITY_STATES = ["resolved", "resolved_with_pedigree", "not_affected"] class BaseRule: @@ -27,6 +35,13 @@ def get_package_filter(self): """Return queryset filter kwargs for ProductPackage to identify violating packages.""" return {} + def filter_queryset(self, queryset, parameters=None): + """Filter a ProductPackage queryset to packages that violate this rule.""" + package_filter = self.get_package_filter() + if package_filter: + return queryset.filter(**package_filter) + return queryset + class PackageBaseRule(BaseRule): """Base for rules that count packages matching a fixed filter within a product.""" @@ -55,9 +70,7 @@ class UsagePolicyErrorRule(PackageBaseRule): rule_type = "usage_policy_error" label = "Usage Policy Error" severity = "error" - description = ( - "Detects packages assigned a usage policy with a compliance alert level of 'error'." - ) + description = "Detects packages assigned a usage policy flagged with an error compliance alert." package_filter = {"usage_policy__compliance_alert": "error"} @@ -65,7 +78,7 @@ class UsagePolicyWarningRule(PackageBaseRule): rule_type = "usage_policy_warning" label = "Usage Policy Warning" description = ( - "Detects packages assigned a usage policy with a compliance alert level of 'warning'." + "Detects packages assigned a usage policy flagged with a warning compliance alert." ) package_filter = {"usage_policy__compliance_alert": "warning"} @@ -76,7 +89,7 @@ class LicensePolicyErrorRule(PackageBaseRule): severity = "error" description = ( "Detects packages whose licenses are assigned a usage policy" - " with a compliance alert level of 'error'." + " flagged with an error compliance alert." ) package_filter = {"licenses__usage_policy__compliance_alert": "error"} @@ -86,7 +99,7 @@ class LicensePolicyWarningRule(PackageBaseRule): label = "License Policy Warning" description = ( "Detects packages whose licenses are assigned a usage policy" - " with a compliance alert level of 'warning'." + " flagged with a warning compliance alert." ) package_filter = {"licenses__usage_policy__compliance_alert": "warning"} @@ -94,10 +107,118 @@ class LicensePolicyWarningRule(PackageBaseRule): class LicenseCoverageGapRule(PackageBaseRule): rule_type = "license_coverage_gap" label = "License Coverage Gap" + description = "Detects packages with no license expression." + package_filter = {"license_expression": ""} + + +class VulnerabilityDetectedRule(BaseRule): + rule_type = "vulnerability_detected" + label = "Vulnerability Detected" + severity = "error" + description = "Detects packages with at least one known vulnerability." + parameters_schema = { + "min_risk_score": "Minimum risk score (0.0-10.0). Default: any vulnerability.", + } + + def filter_queryset(self, queryset, parameters=None): + parameters = parameters or {} + min_risk_score = parameters.get("min_risk_score") + if min_risk_score is not None: + return queryset.filter( + package__affected_by_vulnerabilities__risk_score__gte=min_risk_score + ) + return queryset.filter(package__affected_by_vulnerabilities__isnull=False) + + def count_violations(self, product, threshold, parameters): + ProductPackage = apps.get_model("product_portfolio", "productpackage") + product_packages = ProductPackage.objects.filter(product=product) + count = self.filter_queryset(product_packages, parameters).distinct().count() + return count if count > threshold else 0 + + +class UnresolvedVulnerabilityRule(BaseRule): + rule_type = "vulnerability_unresolved" + label = "Vulnerability Unresolved" + severity = "warning" description = ( - "Detects packages with no license expression, indicating a gap in license coverage." + "Detects packages with known vulnerabilities and no completed vulnerability analysis." ) - package_filter = {"license_expression": ""} + + def filter_queryset(self, queryset, parameters=None): + PackageAffectedByVulnerability = apps.get_model( + "component_catalog", "packageaffectedbyvulnerability" + ) + VulnerabilityAnalysis = apps.get_model("vulnerabilities", "vulnerabilityanalysis") + + terminal_analysis = VulnerabilityAnalysis.objects.filter( + product_package=OuterRef(OuterRef("pk")), + state__in=TERMINAL_VULNERABILITY_STATES, + vulnerability=OuterRef("vulnerability"), + ) + unresolved_link = ( + PackageAffectedByVulnerability.objects.filter(package=OuterRef("package")) + .annotate(has_terminal=Exists(terminal_analysis)) + .filter(has_terminal=False) + ) + return queryset.filter(Exists(unresolved_link)) + + def count_violations(self, product, threshold, parameters): + ProductPackage = apps.get_model("product_portfolio", "productpackage") + product_packages = ProductPackage.objects.filter(product=product) + count = self.filter_queryset(product_packages).distinct().count() + return count if count > threshold else 0 + + +class StaleVulnerabilityRule(BaseRule): + rule_type = "vulnerability_stale" + label = "Vulnerability Stale" + severity = "error" + description = ( + "Detects packages with high-risk vulnerabilities unaddressed" + " for more than the configured number of days." + ) + parameters_schema = { + "max_days": ( + "Maximum number of days a vulnerability may remain unaddressed before flagging. " + "Default: 30." + ), + "min_risk_score": ( + "Only consider vulnerabilities with at least this risk score. Default: 8.0." + ), + } + + def filter_queryset(self, queryset, parameters=None): + PackageAffectedByVulnerability = apps.get_model( + "component_catalog", "packageaffectedbyvulnerability" + ) + VulnerabilityAnalysis = apps.get_model("vulnerabilities", "vulnerabilityanalysis") + + parameters = parameters or {} + max_days = parameters.get("max_days", 30) + min_risk_score = parameters.get("min_risk_score", 8.0) + cutoff_date = timezone.now() - timedelta(days=max_days) + + terminal_analysis = VulnerabilityAnalysis.objects.filter( + product_package=OuterRef(OuterRef("pk")), + state__in=TERMINAL_VULNERABILITY_STATES, + vulnerability=OuterRef("vulnerability"), + ) + stale_link = ( + PackageAffectedByVulnerability.objects.filter( + package=OuterRef("package"), + vulnerability__risk_score__gte=min_risk_score, + detected_date__lte=cutoff_date, + ) + .annotate(has_terminal=Exists(terminal_analysis)) + .filter(has_terminal=False) + ) + return queryset.filter(Exists(stale_link)) + + def count_violations(self, product, threshold, parameters): + ProductPackage = apps.get_model("product_portfolio", "productpackage") + product_packages = ProductPackage.objects.filter(product=product) + count = self.filter_queryset(product_packages, parameters).distinct().count() + return count if count > threshold else 0 RULE_REGISTRY = { @@ -106,4 +227,7 @@ class LicenseCoverageGapRule(PackageBaseRule): LicensePolicyErrorRule.rule_type: LicensePolicyErrorRule(), LicensePolicyWarningRule.rule_type: LicensePolicyWarningRule(), LicenseCoverageGapRule.rule_type: LicenseCoverageGapRule(), + VulnerabilityDetectedRule.rule_type: VulnerabilityDetectedRule(), + UnresolvedVulnerabilityRule.rule_type: UnresolvedVulnerabilityRule(), + StaleVulnerabilityRule.rule_type: StaleVulnerabilityRule(), } diff --git a/policy/tests/test_rules.py b/policy/tests/test_rules.py new file mode 100644 index 00000000..4c52ebfa --- /dev/null +++ b/policy/tests/test_rules.py @@ -0,0 +1,277 @@ +# +# 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 datetime import timedelta + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from django.utils import timezone + +from component_catalog.models import Package +from component_catalog.models import PackageAffectedByVulnerability +from component_catalog.tests import make_package +from dje.models import Dataspace +from license_library.models import License +from license_library.tests import make_license +from policy.models import UsagePolicy +from policy.rules import LicenseCoverageGapRule +from policy.rules import LicensePolicyErrorRule +from policy.rules import LicensePolicyWarningRule +from policy.rules import StaleVulnerabilityRule +from policy.rules import UnresolvedVulnerabilityRule +from policy.rules import UsagePolicyErrorRule +from policy.rules import UsagePolicyWarningRule +from policy.rules import VulnerabilityDetectedRule +from product_portfolio.tests import make_product +from product_portfolio.tests import make_product_package +from vulnerabilities.tests import make_vulnerability +from vulnerabilities.tests import make_vulnerability_analysis + + +class UsagePolicyErrorRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.policy = UsagePolicy.objects.create( + label="Prohibited", + icon="icon", + content_type=ContentType.objects.get_for_model(Package), + compliance_alert=UsagePolicy.Compliance.ERROR, + dataspace=self.dataspace, + ) + + def test_counts_packages_with_error_usage_policy(self): + package = make_package(self.dataspace, usage_policy=self.policy) + make_product_package(self.product, package=package) + make_product_package(self.product, package=make_package(self.dataspace)) + count = UsagePolicyErrorRule().count_violations(self.product, 0, {}) + self.assertEqual(1, count) + + def test_returns_zero_when_no_matching_packages(self): + make_product_package(self.product, package=make_package(self.dataspace)) + count = UsagePolicyErrorRule().count_violations(self.product, 0, {}) + self.assertEqual(0, count) + + +class UsagePolicyWarningRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.policy = UsagePolicy.objects.create( + label="Restricted", + icon="icon", + content_type=ContentType.objects.get_for_model(Package), + compliance_alert=UsagePolicy.Compliance.WARNING, + dataspace=self.dataspace, + ) + + def test_counts_packages_with_warning_usage_policy(self): + package = make_package(self.dataspace, usage_policy=self.policy) + make_product_package(self.product, package=package) + count = UsagePolicyWarningRule().count_violations(self.product, 0, {}) + self.assertEqual(1, count) + + +class LicensePolicyErrorRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.policy = UsagePolicy.objects.create( + label="Prohibited", + icon="icon", + content_type=ContentType.objects.get_for_model(License), + compliance_alert=UsagePolicy.Compliance.ERROR, + dataspace=self.dataspace, + ) + + def test_counts_packages_with_license_error_policy(self): + license_ = make_license(self.dataspace, key="gpl-3.0", usage_policy=self.policy) + package = make_package(self.dataspace) + package.licenses.add(license_, through_defaults={"dataspace": self.dataspace}) + make_product_package(self.product, package=package) + count = LicensePolicyErrorRule().count_violations(self.product, 0, {}) + self.assertEqual(1, count) + + def test_returns_zero_when_license_has_no_policy(self): + license_ = make_license(self.dataspace, key="mit") + package = make_package(self.dataspace) + package.licenses.add(license_, through_defaults={"dataspace": self.dataspace}) + make_product_package(self.product, package=package) + count = LicensePolicyErrorRule().count_violations(self.product, 0, {}) + self.assertEqual(0, count) + + +class LicensePolicyWarningRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.policy = UsagePolicy.objects.create( + label="Restricted", + icon="icon", + content_type=ContentType.objects.get_for_model(License), + compliance_alert=UsagePolicy.Compliance.WARNING, + dataspace=self.dataspace, + ) + + def test_counts_packages_with_license_warning_policy(self): + license_ = make_license(self.dataspace, key="lgpl-2.1", usage_policy=self.policy) + package = make_package(self.dataspace) + package.licenses.add(license_, through_defaults={"dataspace": self.dataspace}) + make_product_package(self.product, package=package) + count = LicensePolicyWarningRule().count_violations(self.product, 0, {}) + self.assertEqual(1, count) + + +class LicenseCoverageGapRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def test_counts_packages_with_no_license_expression(self): + make_product_package( + self.product, package=make_package(self.dataspace, license_expression="") + ) + make_product_package( + self.product, package=make_package(self.dataspace, license_expression="mit") + ) + count = LicenseCoverageGapRule().count_violations(self.product, 0, {}) + self.assertEqual(1, count) + + def test_returns_zero_when_all_packages_have_license(self): + make_product_package( + self.product, package=make_package(self.dataspace, license_expression="mit") + ) + count = LicenseCoverageGapRule().count_violations(self.product, 0, {}) + self.assertEqual(0, count) + + +class VulnerabilityDetectedRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def test_counts_packages_with_any_vulnerability(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package) + make_product_package(self.product, package=package) + make_product_package(self.product, package=make_package(self.dataspace)) + count = VulnerabilityDetectedRule().count_violations(self.product, 0, {}) + self.assertEqual(1, count) + + def test_min_risk_score_excludes_packages_below_threshold(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package, risk_score=4.0) + make_product_package(self.product, package=package) + count = VulnerabilityDetectedRule().count_violations( + self.product, 0, {"min_risk_score": 7.0} + ) + self.assertEqual(0, count) + + def test_min_risk_score_includes_packages_at_or_above_threshold(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + make_product_package(self.product, package=package) + count = VulnerabilityDetectedRule().count_violations( + self.product, 0, {"min_risk_score": 7.0} + ) + self.assertEqual(1, count) + + +class UnresolvedVulnerabilityRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def test_counts_unanalyzed_package_vulnerability_links(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package) + make_product_package(self.product, package=package) + count = UnresolvedVulnerabilityRule().count_violations(self.product, 0, {}) + self.assertEqual(1, count) + + def test_does_not_count_links_with_terminal_analysis(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + product_package = make_product_package(self.product, package=package) + make_vulnerability_analysis(product_package, vulnerability, state="resolved") + count = UnresolvedVulnerabilityRule().count_violations(self.product, 0, {}) + self.assertEqual(0, count) + + def test_does_not_count_links_with_resolved_with_pedigree_analysis(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + product_package = make_product_package(self.product, package=package) + make_vulnerability_analysis(product_package, vulnerability, state="resolved_with_pedigree") + count = UnresolvedVulnerabilityRule().count_violations(self.product, 0, {}) + self.assertEqual(0, count) + + def test_does_not_count_links_with_not_affected_analysis(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + product_package = make_product_package(self.product, package=package) + make_vulnerability_analysis(product_package, vulnerability, state="not_affected") + count = UnresolvedVulnerabilityRule().count_violations(self.product, 0, {}) + self.assertEqual(0, count) + + +class StaleVulnerabilityRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def test_counts_old_unresolved_high_risk_links(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + make_product_package(self.product, package=package) + old_date = timezone.now() - timedelta(days=60) + PackageAffectedByVulnerability.objects.filter( + package=package, vulnerability=vulnerability + ).update(detected_date=old_date) + count = StaleVulnerabilityRule().count_violations(self.product, 0, {}) + self.assertEqual(1, count) + + def test_does_not_count_recent_links(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + make_product_package(self.product, package=package) + count = StaleVulnerabilityRule().count_violations(self.product, 0, {}) + self.assertEqual(0, count) + + def test_does_not_count_links_below_min_risk_score(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=3.0) + make_product_package(self.product, package=package) + old_date = timezone.now() - timedelta(days=60) + PackageAffectedByVulnerability.objects.filter( + package=package, vulnerability=vulnerability + ).update(detected_date=old_date) + count = StaleVulnerabilityRule().count_violations(self.product, 0, {}) + self.assertEqual(0, count) + + def test_custom_max_days_triggers_for_links_within_window(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + make_product_package(self.product, package=package) + recent_date = timezone.now() - timedelta(days=10) + PackageAffectedByVulnerability.objects.filter( + package=package, vulnerability=vulnerability + ).update(detected_date=recent_date) + count = StaleVulnerabilityRule().count_violations(self.product, 0, {"max_days": 5}) + self.assertEqual(1, count) + + def test_does_not_count_stale_links_with_terminal_analysis(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + product_package = make_product_package(self.product, package=package) + old_date = timezone.now() - timedelta(days=60) + PackageAffectedByVulnerability.objects.filter( + package=package, vulnerability=vulnerability + ).update(detected_date=old_date) + make_vulnerability_analysis(product_package, vulnerability, state="resolved") + count = StaleVulnerabilityRule().count_violations(self.product, 0, {}) + self.assertEqual(0, count) diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index f6521aae..06134052 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -264,6 +264,21 @@ class BaseProductRelationFilterSet(DataspacedFilterSet): field_name="licenses__usage_policy__compliance_alert", distinct=True, ) + policy_rule = django_filters.CharFilter( + label=_("Policy rule"), + method="filter_by_policy_rule", + ) + + def filter_by_policy_rule(self, queryset, name, value): + """Filter packages that triggered the given policy rule type.""" + if queryset.model is not ProductPackage: + return queryset.none() + handler = RULE_REGISTRY.get(value) + if not handler: + return queryset + rules_config = self.dataspace.get_configuration("policy_rules_config") or {} + parameters = rules_config.get(value, {}).get("parameters", {}) + return handler.filter_queryset(queryset, parameters).distinct() @staticmethod def filter_object_type(queryset, name, value): @@ -408,7 +423,6 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet): field_name="package__usage_policy__compliance_alert", distinct=True, ) - policy_rule = django_filters.CharFilter(method="filter_by_policy_rule") class Meta: model = ProductPackage @@ -424,13 +438,6 @@ class Meta: "exploitability", ] - def filter_by_policy_rule(self, queryset, name, value): - """Filter packages that triggered the given policy rule type.""" - handler = RULE_REGISTRY.get(value) - if not handler: - return queryset - return queryset.filter(**handler.get_package_filter()) - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.filters["vulnerability_analyses__state"].extra["null_label"] = "(No values)" diff --git a/product_portfolio/migrations/0019_productaffectedbyvulnerability_detected_date.py b/product_portfolio/migrations/0019_productaffectedbyvulnerability_detected_date.py new file mode 100644 index 00000000..74e8ee2e --- /dev/null +++ b/product_portfolio/migrations/0019_productaffectedbyvulnerability_detected_date.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.6 on 2026-07-22 13:35 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product_portfolio', '0018_productpolicyviolation'), + ] + + operations = [ + migrations.AddField( + model_name='productaffectedbyvulnerability', + name='detected_date', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Date and time when this vulnerability was first detected on this object. Used to measure how long a vulnerability has remained unaddressed. Defaults to the time the record was created.'), + preserve_default=False, + ), + ] diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 70ddb03d..b540f38b 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -252,6 +252,7 @@ def with_policy_violation_count(self): subquery = ( ProductPolicyViolation.objects.filter( product=OuterRef("pk"), + rule_type__in=RULE_REGISTRY.keys(), ) .unresolved() .values("product") diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html index 550bc451..1f657cd7 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -11,8 +11,8 @@