diff --git a/dejacode/settings.py b/dejacode/settings.py
index 8adfdf39..7c9af8c3 100644
--- a/dejacode/settings.py
+++ b/dejacode/settings.py
@@ -475,7 +475,9 @@ def gettext_noop(s):
# Cron jobs (scheduler)
daily_at_3am = "0 3 * * *"
+hourly = "0 * * * *"
DEJACODE_VULNERABILITIES_CRON = env.str("DEJACODE_VULNERABILITIES_CRON", default=daily_at_3am)
+DEJACODE_POLICY_RULES_CRON = env.str("DEJACODE_POLICY_RULES_CRON", default=hourly)
def enable_rq_eager_mode():
diff --git a/dje/admin.py b/dje/admin.py
index 6aa131b5..02d1c9f3 100644
--- a/dje/admin.py
+++ b/dje/admin.py
@@ -96,6 +96,7 @@
from dje.views import manage_tab_permissions_view
from dje.views import object_compare_view
from dje.views import object_copy_view
+from policy.rules import RULE_REGISTRY
EXTERNAL_SOURCE_LOOKUP = "external_references__external_source_id"
@@ -1046,12 +1047,11 @@ def render(self, name, value, attrs=None, renderer=None):
class DataspaceConfigurationForm(forms.ModelForm):
- """
- Configure Dataspace settings.
+ """Configure Dataspace integration settings, with sensitive values hidden in the UI."""
- This form includes fields for various API keys, with sensitive values
- hidden in the UI using the HiddenValueWidget.
- """
+ class Meta:
+ model = DataspaceConfiguration
+ exclude = ["policy_rules_config"]
hidden_value_fields = [
"scancodeio_api_key",
@@ -1078,6 +1078,73 @@ def clean(self):
del self.cleaned_data[field_name]
+class PolicyRulesConfigurationForm(forms.ModelForm):
+ """Form for configuring policy rule overrides stored in policy_rules_config."""
+
+ class Meta:
+ model = DataspaceConfiguration
+ fields = []
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.add_policy_rule_config_fields()
+
+ def add_policy_rule_config_fields(self):
+ """Inject per-rule form fields with initial values from the saved policy_rules_config."""
+ config = getattr(self.instance, "policy_rules_config", {}) or {}
+ for rule_type, handler in RULE_REGISTRY.items():
+ rule_config = config.get(rule_type, {})
+ self.fields[f"rule_{rule_type}_disabled"] = forms.BooleanField(
+ label="Disable this rule",
+ required=False,
+ initial=not rule_config.get("is_active", True),
+ )
+ self.fields[f"rule_{rule_type}_threshold"] = forms.IntegerField(
+ label="Threshold",
+ required=False,
+ min_value=0,
+ initial=rule_config.get("threshold"),
+ widget=forms.NumberInput(
+ attrs={"placeholder": f"Default: {handler.default_threshold}"}
+ ),
+ help_text="Minimum violations to trigger the rule. Leave blank to use the default.",
+ )
+ for param_name, param_desc in handler.parameters_schema.items():
+ self.fields[f"rule_{rule_type}_param_{param_name}"] = forms.FloatField(
+ label=param_name.replace("_", " ").title(),
+ required=False,
+ initial=(rule_config.get("parameters") or {}).get(param_name),
+ help_text=param_desc,
+ )
+
+ def build_policy_rules_config(self):
+ """Serialize the per-rule form fields back into the policy_rules_config dict."""
+ policy_rules_config = {}
+ for rule_type, handler in RULE_REGISTRY.items():
+ rule_config = {}
+ if self.cleaned_data.get(f"rule_{rule_type}_disabled"):
+ rule_config["is_active"] = False
+ threshold = self.cleaned_data.get(f"rule_{rule_type}_threshold")
+ if threshold is not None:
+ rule_config["threshold"] = threshold
+ parameters = {}
+ for param_name in handler.parameters_schema:
+ param_value = self.cleaned_data.get(f"rule_{rule_type}_param_{param_name}")
+ if param_value is not None:
+ parameters[param_name] = param_value
+ if parameters:
+ rule_config["parameters"] = parameters
+ if rule_config:
+ policy_rules_config[rule_type] = rule_config
+ return policy_rules_config
+
+ def save(self, commit=True):
+ self.instance.policy_rules_config = self.build_policy_rules_config()
+ if commit:
+ self.instance.save(update_fields=["policy_rules_config"])
+ return self.instance
+
+
class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
model = DataspaceConfiguration
form = DataspaceConfigurationForm
@@ -1142,12 +1209,13 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
]
# Do not include the Dataspace related FKs on addition as the Dataspace does not exist yet
fieldsets = [("", {"fields": ("homepage_layout",)})] + add_fieldsets
+ inline_classes = ("grp-collapse grp-open",)
can_delete = False
def get_fieldsets(self, request, obj=None):
if not obj:
return self.add_fieldsets
- return super().get_fieldsets(request, obj)
+ return [("", {"fields": ("homepage_layout",)})] + self.add_fieldsets
def get_readonly_fields(self, request, obj=None):
"""Only a user from the current Dataspace can edit Dataspace related FKs."""
@@ -1160,6 +1228,40 @@ def get_readonly_fields(self, request, obj=None):
return readonly_fields
+class PolicyRulesConfigurationInline(DataspacedFKMixin, admin.StackedInline):
+ model = DataspaceConfiguration
+ form = PolicyRulesConfigurationForm
+ verbose_name_plural = _("Policy Rules Configuration")
+ verbose_name = _("Policy Rules Configuration")
+ classes = ("grp-collapse grp-open",)
+ inline_classes = ("grp-collapse grp-open",)
+ can_delete = False
+
+ def get_fieldsets(self, request, obj=None):
+ if not obj:
+ return []
+ rule_fieldsets = []
+ for rule_type, handler in RULE_REGISTRY.items():
+ fields = [f"rule_{rule_type}_disabled", f"rule_{rule_type}_threshold"]
+ for param_name in handler.parameters_schema:
+ fields.append(f"rule_{rule_type}_param_{param_name}")
+ rule_fieldsets.append(
+ (
+ handler.label,
+ {
+ "fields": fields,
+ "description": handler.description,
+ "classes": ("grp-collapse grp-open",),
+ },
+ )
+ )
+ return rule_fieldsets
+
+ def get_formset(self, request, obj=None, **kwargs):
+ kwargs["fields"] = []
+ return super().get_formset(request, obj, **kwargs)
+
+
@admin.register(Dataspace, site=dejacode_site)
class DataspaceAdmin(
ReferenceOnlyPermissions,
@@ -1239,7 +1341,7 @@ class DataspaceAdmin(
),
)
search_fields = ("name",)
- inlines = [DataspaceConfigurationInline]
+ inlines = [DataspaceConfigurationInline, PolicyRulesConfigurationInline]
form = DataspaceAdminForm
change_form_template = "admin/dje/dataspace/change_form.html"
change_list_template = "admin/change_list_extended.html"
diff --git a/dje/cron_jobs.py b/dje/cron_jobs.py
index 38cd35fc..1631a15c 100644
--- a/dje/cron_jobs.py
+++ b/dje/cron_jobs.py
@@ -11,6 +11,7 @@
from rq import cron
from dje.tasks import update_vulnerabilities
+from policy.tasks import evaluate_all_products_rules_task
two_hour = 7200
@@ -20,3 +21,10 @@
cron=settings.DEJACODE_VULNERABILITIES_CRON, # Daily at 3am by default
job_timeout=two_hour,
)
+
+cron.register(
+ func=evaluate_all_products_rules_task,
+ queue_name="default",
+ cron=settings.DEJACODE_POLICY_RULES_CRON, # Hourly by default
+ job_timeout=two_hour,
+)
diff --git a/dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py b/dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py
new file mode 100644
index 00000000..340b8226
--- /dev/null
+++ b/dje/migrations/0016_dataspaceconfiguration_policy_rules_config.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.6 on 2026-07-15 08:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='dataspaceconfiguration',
+ name='policy_rules_config',
+ field=models.JSONField(blank=True, default=dict, help_text='Override default policy rule settings for this dataspace.'),
+ ),
+ ]
diff --git a/dje/models.py b/dje/models.py
index 2d87316d..996195ce 100644
--- a/dje/models.py
+++ b/dje/models.py
@@ -614,6 +614,12 @@ class DataspaceConfiguration(DataspaceForeignKeyValidationMixin, models.Model):
),
)
+ policy_rules_config = models.JSONField(
+ blank=True,
+ default=dict,
+ help_text=_("Override default policy rule settings for this dataspace."),
+ )
+
def __str__(self):
return f"{self.dataspace}"
diff --git a/notification/admin.py b/notification/admin.py
index d683bec2..a9d74656 100644
--- a/notification/admin.py
+++ b/notification/admin.py
@@ -64,3 +64,9 @@ class WebhookSubscriptionAdmin(ProhibitDataspaceLookupMixin, DataspacedAdmin):
actions_to_remove = ["copy_to", "compare_with"]
email_notification_on = ()
inlines = [WebhookDeliveryInline]
+
+ def get_inlines(self, request, obj=None):
+ """Exclude delivery history on the add form as no deliveries exist yet."""
+ if obj is None:
+ return []
+ return super().get_inlines(request, obj)
diff --git a/notification/models.py b/notification/models.py
index 661ef5a4..b8676a70 100644
--- a/notification/models.py
+++ b/notification/models.py
@@ -28,6 +28,8 @@
"user.added_or_updated",
"user.locked_out",
"vulnerability.data_update",
+ "policy.violation_detected",
+ "policy.violation_resolved",
]
diff --git a/notification/tasks.py b/notification/tasks.py
index 7c42c97e..e8222e33 100644
--- a/notification/tasks.py
+++ b/notification/tasks.py
@@ -12,6 +12,7 @@
from django_rq import job
+from dje.models import get_unsecured_manager
from notification.models import WebhookSubscription
logger = logging.getLogger("dje")
@@ -36,7 +37,7 @@ def deliver_webhook_task(
if instance_app_label and instance_model_name and instance_pk:
try:
model_class = apps.get_model(instance_app_label, instance_model_name)
- instance = model_class.objects.get(pk=instance_pk)
+ instance = get_unsecured_manager(model_class).get(pk=instance_pk)
except Exception:
logger.error(
f"Instance {instance_app_label}.{instance_model_name} pk={instance_pk} not found."
diff --git a/notification/tests/test_tasks.py b/notification/tests/test_tasks.py
index 6ee03ad3..ce0227ec 100644
--- a/notification/tests/test_tasks.py
+++ b/notification/tests/test_tasks.py
@@ -7,6 +7,7 @@
#
import json
+import uuid
from unittest.mock import patch
from django.conf import settings
@@ -16,6 +17,8 @@
from dje.models import Dataspace
from dje.tests import create_superuser
from notification.models import WebhookSubscription
+from notification.tasks import deliver_webhook_task
+from product_portfolio.tests import make_product
from workflow.models import Priority
from workflow.models import Question
from workflow.models import Request
@@ -23,6 +26,45 @@
from workflow.models import RequestTemplate
+class DeliverWebhookTaskTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.webhook = WebhookSubscription.objects.create(
+ dataspace=self.dataspace,
+ target_url="http://127.0.0.1:8000/",
+ event="policy.violation_detected",
+ )
+
+ @patch("notification.models.WebhookSubscription.deliver")
+ def test_deliver_webhook_task_resolves_secured_model_instance(self, mock_deliver):
+ product = make_product(self.dataspace)
+ deliver_webhook_task(
+ webhook_subscription_uuid=self.webhook.uuid,
+ payload_override={"text": "test"},
+ instance_app_label="product_portfolio",
+ instance_model_name="product",
+ instance_pk=product.pk,
+ )
+ mock_deliver.assert_called_once()
+ delivered_instance = mock_deliver.call_args[0][0]
+ self.assertEqual(product, delivered_instance)
+
+ def test_deliver_webhook_task_missing_subscription_logs_error(self):
+ with self.assertLogs("dje", level="ERROR") as captured:
+ deliver_webhook_task(webhook_subscription_uuid=uuid.uuid4())
+ self.assertTrue(any("not found" in line for line in captured.output))
+
+ def test_deliver_webhook_task_missing_instance_logs_error(self):
+ with self.assertLogs("dje", level="ERROR") as captured:
+ deliver_webhook_task(
+ webhook_subscription_uuid=self.webhook.uuid,
+ instance_app_label="product_portfolio",
+ instance_model_name="product",
+ instance_pk=99999999,
+ )
+ self.assertTrue(any("not found" in line for line in captured.output))
+
+
class NotificationTasksTestCase(TestCase):
def setUp(self):
self.nexb_dataspace = Dataspace.objects.create(name="nexB")
diff --git a/policy/apps.py b/policy/apps.py
index e67945dc..392564e7 100755
--- a/policy/apps.py
+++ b/policy/apps.py
@@ -13,3 +13,6 @@
class PolicyConfig(AppConfig):
name = "policy"
verbose_name = _("Policy")
+
+ def ready(self):
+ import policy.signals # noqa: F401
diff --git a/policy/engine.py b/policy/engine.py
new file mode 100644
index 00000000..142b005b
--- /dev/null
+++ b/policy/engine.py
@@ -0,0 +1,94 @@
+#
+# 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.utils import timezone
+
+from policy.rules import RULE_REGISTRY
+from product_portfolio.models import ProductPolicyViolation
+
+
+def get_effective_config(rule_type, dataspace):
+ """
+ Resolve threshold, parameters, and is_active for a rule type in a given dataspace.
+
+ Reads the dataspace-level override from DataspaceConfiguration.policy_rules_config,
+ falling back to the code defaults defined on the rule handler.
+ """
+ handler = RULE_REGISTRY[rule_type]
+ try:
+ rule_config = dataspace.configuration.policy_rules_config.get(rule_type, {})
+ except AttributeError:
+ rule_config = {}
+
+ return {
+ "is_active": rule_config.get("is_active", True),
+ "threshold": rule_config.get("threshold", handler.default_threshold),
+ "parameters": rule_config.get("parameters", {}),
+ }
+
+
+def evaluate_rule(rule_type, product, threshold, parameters):
+ """
+ Evaluate a single rule against a product and record the violation if triggered.
+
+ Returns a 3-tuple: (violation_or_none, created, resolved_count).
+ """
+ handler = RULE_REGISTRY[rule_type]
+ violation_count = handler.count_violations(product, threshold, parameters)
+
+ lookup = {"rule_type": rule_type, "product": product, "resolved": False}
+
+ if violation_count > 0:
+ violation, created = ProductPolicyViolation.objects.get_or_create(
+ **lookup,
+ defaults={"dataspace": product.dataspace, "violation_count": violation_count},
+ )
+ if not created:
+ violation.violation_count = violation_count
+ violation.save()
+ return violation, created, 0
+
+ resolved_count = ProductPolicyViolation.objects.filter(**lookup).update(
+ resolved=True,
+ resolved_date=timezone.now(),
+ )
+ return None, False, resolved_count
+
+
+def evaluate_rules(product):
+ """
+ Evaluate all rules in RULE_REGISTRY for the given product.
+
+ Returns a 2-tuple: (new_violations, resolved_count).
+ new_violations is a list of newly created ProductPolicyViolation instances.
+ resolved_count is the total number of violations resolved during this run.
+ """
+ new_violations = []
+ resolved_count = 0
+
+ for rule_type in RULE_REGISTRY:
+ config = get_effective_config(rule_type, product.dataspace)
+ if not config["is_active"]:
+ # Explicitly resolve open violations so disabling a rule clears its history
+ # rather than leaving stale unresolved records.
+ rows = ProductPolicyViolation.objects.filter(
+ rule_type=rule_type,
+ product=product,
+ resolved=False,
+ ).update(resolved=True, resolved_date=timezone.now())
+ resolved_count += rows
+ continue
+
+ violation, created, resolved = evaluate_rule(
+ rule_type, product, config["threshold"], config["parameters"]
+ )
+ if created:
+ new_violations.append(violation)
+ resolved_count += resolved
+
+ return new_violations, resolved_count
diff --git a/policy/models.py b/policy/models.py
index cc731855..ed649d0d 100755
--- a/policy/models.py
+++ b/policy/models.py
@@ -244,3 +244,32 @@ def save(self, *args, **kwargs):
if self.from_policy.content_type == self.to_policy.content_type:
raise AssertionError
super().save(*args, **kwargs)
+
+
+class AbstractPolicyViolation(models.Model):
+ """Shared fields for all concrete policy violation models. No DB table."""
+
+ violation_count = models.PositiveIntegerField(
+ default=0,
+ help_text=_("Number of objects currently violating the rule."),
+ )
+ detected_date = models.DateTimeField(
+ auto_now_add=True,
+ help_text=_("The date and time when this violation was first detected."),
+ )
+ last_checked = models.DateTimeField(
+ auto_now=True,
+ help_text=_("The date and time of the last evaluation."),
+ )
+ resolved = models.BooleanField(
+ default=False,
+ help_text=_("Indicates whether this violation has been resolved."),
+ )
+ resolved_date = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text=_("The date and time when this violation was resolved."),
+ )
+
+ class Meta:
+ abstract = True
diff --git a/policy/rules.py b/policy/rules.py
new file mode 100644
index 00000000..94d56a15
--- /dev/null
+++ b/policy/rules.py
@@ -0,0 +1,138 @@
+#
+# 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.apps import apps
+
+
+class BaseRule:
+ """Base class for policy rule handlers."""
+
+ rule_type = None
+ label = None
+ description = None
+ severity = "warning"
+ default_threshold = 0
+ parameters_schema = {}
+
+ def count_violations(self, product, threshold, parameters):
+ """Count objects violating the rule for the given product."""
+ raise NotImplementedError
+
+ def get_package_filter(self):
+ """Return queryset filter kwargs for ProductPackage to identify violating packages."""
+ return {}
+
+
+class PackageBaseRule(BaseRule):
+ """Base for rules that count packages matching a fixed filter within a product."""
+
+ package_filter = {}
+
+ def count_violations(self, product, threshold, parameters):
+ Package = apps.get_model("component_catalog", "package")
+
+ count = (
+ Package.objects.filter(
+ productpackages__product=product,
+ **self.package_filter,
+ )
+ .distinct()
+ .count()
+ )
+
+ return count if count > threshold else 0
+
+ def get_package_filter(self):
+ return {f"package__{key}": value for key, value in self.package_filter.items()}
+
+
+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'."
+ )
+ package_filter = {"usage_policy__compliance_alert": "error"}
+
+
+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'."
+ )
+ package_filter = {"usage_policy__compliance_alert": "warning"}
+
+
+class LicensePolicyErrorRule(PackageBaseRule):
+ rule_type = "license_policy_error"
+ label = "License Policy Error"
+ severity = "error"
+ description = (
+ "Detects packages whose licenses are assigned a usage policy"
+ " with a compliance alert level of 'error'."
+ )
+ package_filter = {"licenses__usage_policy__compliance_alert": "error"}
+
+
+class LicensePolicyWarningRule(PackageBaseRule):
+ rule_type = "license_policy_warning"
+ label = "License Policy Warning"
+ description = (
+ "Detects packages whose licenses are assigned a usage policy"
+ " with a compliance alert level of 'warning'."
+ )
+ package_filter = {"licenses__usage_policy__compliance_alert": "warning"}
+
+
+class LicenseCoverageGapRule(PackageBaseRule):
+ rule_type = "license_coverage_gap"
+ label = "License Coverage Gap"
+ description = (
+ "Detects packages with no license expression, indicating a gap in license coverage."
+ )
+ 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 (non-null risk score)."
+ parameters_schema = {
+ "min_risk_score": "Minimum risk score (0.0-10.0). Default: any vulnerability.",
+ }
+
+ def get_package_filter(self):
+ return {"package__risk_score__isnull": False}
+
+ def count_violations(self, product, threshold, parameters):
+ Package = apps.get_model("component_catalog", "package")
+
+ packages = Package.objects.filter(
+ productpackages__product=product,
+ risk_score__isnull=False,
+ )
+
+ min_risk_score = parameters.get("min_risk_score")
+ if min_risk_score is not None:
+ packages = packages.filter(risk_score__gte=min_risk_score)
+
+ count = packages.count()
+ return count if count > threshold else 0
+
+
+RULE_REGISTRY = {
+ UsagePolicyErrorRule.rule_type: UsagePolicyErrorRule(),
+ UsagePolicyWarningRule.rule_type: UsagePolicyWarningRule(),
+ LicensePolicyErrorRule.rule_type: LicensePolicyErrorRule(),
+ LicensePolicyWarningRule.rule_type: LicensePolicyWarningRule(),
+ LicenseCoverageGapRule.rule_type: LicenseCoverageGapRule(),
+ VulnerabilityDetectedRule.rule_type: VulnerabilityDetectedRule(),
+}
diff --git a/policy/signals.py b/policy/signals.py
new file mode 100644
index 00000000..e0060f8f
--- /dev/null
+++ b/policy/signals.py
@@ -0,0 +1,47 @@
+#
+# 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.
+#
+
+import logging
+
+from django.db.models.signals import post_delete
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+from policy.tasks import evaluate_all_products_rules_task
+from policy.tasks import evaluate_product_rules_task
+
+logger = logging.getLogger(__name__)
+
+
+@receiver(post_save, sender="product_portfolio.Product")
+def evaluate_product_rules_on_product_save(sender, instance, **kwargs):
+ """Queue a policy rule evaluation whenever a product is saved."""
+ evaluate_product_rules_task.delay(product_uuid=instance.uuid)
+
+
+@receiver(post_save, sender="product_portfolio.ProductPackage")
+def evaluate_product_rules_on_productpackage_save(sender, instance, **kwargs):
+ """Queue a policy rule evaluation whenever a package is added or updated in a product."""
+ evaluate_product_rules_task.delay(product_uuid=instance.product.uuid)
+
+
+@receiver(post_delete, sender="product_portfolio.ProductPackage")
+def evaluate_product_rules_on_productpackage_delete(sender, instance, **kwargs):
+ """Queue a policy rule evaluation whenever a package is removed from a product."""
+ evaluate_product_rules_task.delay(product_uuid=instance.product.uuid)
+
+
+@receiver(post_save, sender="component_catalog.Package")
+def evaluate_product_rules_on_package_save(sender, instance, created, **kwargs):
+ """Queue a policy rule evaluation for all products containing this package."""
+ if created:
+ return # A newly created package has no ProductPackage relations yet.
+
+ product_uuids = list(instance.productpackages.values_list("product__uuid", flat=True))
+ if product_uuids:
+ evaluate_all_products_rules_task.delay(product_uuids=product_uuids)
diff --git a/policy/tasks.py b/policy/tasks.py
new file mode 100644
index 00000000..214b7c6b
--- /dev/null
+++ b/policy/tasks.py
@@ -0,0 +1,87 @@
+#
+# 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.
+#
+
+import logging
+
+from django.apps import apps
+
+from django_rq import job
+
+from dje.models import get_unsecured_manager
+from notification.models import fire_webhooks
+from policy.engine import evaluate_rules
+
+logger = logging.getLogger(__name__)
+
+
+def fire_policy_webhooks(product, new_violations, resolved_count):
+ """Fire policy webhooks for newly detected or resolved violations."""
+ if new_violations:
+ lines = [
+ f"- {violation.rule_label}: {violation.violation_count} violation(s)"
+ for violation in new_violations
+ ]
+ payload = {
+ "text": (f"[DejaCode] Policy violations detected for {product}\n" + "\n".join(lines))
+ }
+ fire_webhooks("policy.violation_detected", instance=product, payload_override=payload)
+
+ if resolved_count:
+ payload = {
+ "text": (f"[DejaCode] {resolved_count} policy violation(s) resolved for {product}")
+ }
+ fire_webhooks("policy.violation_resolved", instance=product, payload_override=payload)
+
+
+@job
+def evaluate_product_rules_task(product_uuid):
+ """Evaluate all active policy rules for the given product and fire webhooks on changes."""
+ Product = apps.get_model("product_portfolio", "product")
+
+ try:
+ product = get_unsecured_manager(Product).get(uuid=product_uuid)
+ except Product.DoesNotExist:
+ logger.error(f"evaluate_product_rules_task: product {product_uuid} not found, skipping.")
+ return
+
+ logger.info(f"Evaluating policy rules for product {product}")
+ new_violations, resolved_count = evaluate_rules(product)
+ logger.info(
+ f"Policy rules evaluated for {product}: "
+ f"{len(new_violations)} new violation(s), {resolved_count} resolved."
+ )
+ fire_policy_webhooks(product, new_violations, resolved_count)
+
+
+@job
+def evaluate_all_products_rules_task(include_locked=False, product_uuids=None):
+ """
+ Evaluate policy rules for products, skipping locked ones by default.
+ When product_uuids is provided, only those products are evaluated.
+ """
+ Product = apps.get_model("product_portfolio", "product")
+
+ products = get_unsecured_manager(Product).select_related("dataspace")
+ if product_uuids is not None:
+ products = products.filter(uuid__in=product_uuids)
+ elif not include_locked:
+ products = products.exclude(configuration_status__is_locked=True)
+
+ count = products.count()
+ logger.info(f"Starting policy rule evaluation for {count} product(s).")
+
+ for product in products:
+ logger.info(f"Evaluating policy rules for product {product}")
+ new_violations, resolved_count = evaluate_rules(product)
+ logger.info(
+ f"Policy rules evaluated for {product}: "
+ f"{len(new_violations)} new violation(s), {resolved_count} resolved."
+ )
+ fire_policy_webhooks(product, new_violations, resolved_count)
+
+ logger.info(f"Policy rule evaluation complete for {count} product(s).")
diff --git a/policy/tests/test_tasks.py b/policy/tests/test_tasks.py
new file mode 100644
index 00000000..31f7c6ed
--- /dev/null
+++ b/policy/tests/test_tasks.py
@@ -0,0 +1,149 @@
+#
+# 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.
+#
+
+import uuid
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+from django.test import TestCase
+
+from dje.models import Dataspace
+from policy.tasks import evaluate_all_products_rules_task
+from policy.tasks import evaluate_product_rules_task
+from policy.tasks import fire_policy_webhooks
+from product_portfolio.tests import make_product
+from product_portfolio.tests import make_product_status
+
+
+class FirePolicyWebhooksTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ @patch("policy.tasks.fire_webhooks")
+ def test_fire_policy_webhooks_dispatches_violation_detected(self, mock_fire):
+ violation = MagicMock()
+ violation.rule_label = "Usage Policy Error"
+ violation.violation_count = 3
+ fire_policy_webhooks(self.product, new_violations=[violation], resolved_count=0)
+ mock_fire.assert_called_once()
+ event_name, kwargs = mock_fire.call_args[0][0], mock_fire.call_args[1]
+ self.assertEqual("policy.violation_detected", event_name)
+ self.assertIn("Policy violations detected", kwargs["payload_override"]["text"])
+ self.assertIn("Usage Policy Error", kwargs["payload_override"]["text"])
+
+ @patch("policy.tasks.fire_webhooks")
+ def test_fire_policy_webhooks_dispatches_violation_resolved(self, mock_fire):
+ fire_policy_webhooks(self.product, new_violations=[], resolved_count=2)
+ mock_fire.assert_called_once()
+ event_name, kwargs = mock_fire.call_args[0][0], mock_fire.call_args[1]
+ self.assertEqual("policy.violation_resolved", event_name)
+ self.assertIn("2 policy violation(s) resolved", kwargs["payload_override"]["text"])
+
+ @patch("policy.tasks.fire_webhooks")
+ def test_fire_policy_webhooks_dispatches_both_events(self, mock_fire):
+ violation = MagicMock()
+ violation.rule_label = "License Coverage Gap"
+ violation.violation_count = 1
+ fire_policy_webhooks(self.product, new_violations=[violation], resolved_count=1)
+ self.assertEqual(2, mock_fire.call_count)
+ events_fired = [c[0][0] for c in mock_fire.call_args_list]
+ self.assertIn("policy.violation_detected", events_fired)
+ self.assertIn("policy.violation_resolved", events_fired)
+
+ @patch("policy.tasks.fire_webhooks")
+ def test_fire_policy_webhooks_silent_when_no_changes(self, mock_fire):
+ fire_policy_webhooks(self.product, new_violations=[], resolved_count=0)
+ mock_fire.assert_not_called()
+
+
+class EvaluateProductRulesTaskTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ @patch("policy.tasks.fire_policy_webhooks")
+ @patch("policy.tasks.evaluate_rules")
+ def test_evaluate_product_rules_task_runs_evaluation(self, mock_evaluate, mock_fire):
+ mock_evaluate.return_value = ([], 0)
+ evaluate_product_rules_task(product_uuid=self.product.uuid)
+ mock_evaluate.assert_called_once_with(self.product)
+ mock_fire.assert_called_once_with(self.product, [], 0)
+
+ @patch("policy.tasks.fire_policy_webhooks")
+ @patch("policy.tasks.evaluate_rules")
+ def test_evaluate_product_rules_task_unknown_uuid_logs_error(self, mock_evaluate, mock_fire):
+ with self.assertLogs("policy.tasks", level="ERROR") as captured:
+ evaluate_product_rules_task(product_uuid=uuid.uuid4())
+ mock_evaluate.assert_not_called()
+ mock_fire.assert_not_called()
+ self.assertTrue(any("not found" in line for line in captured.output))
+
+
+class EvaluateAllProductsRulesTaskTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+
+ @patch("policy.tasks.fire_policy_webhooks")
+ @patch("policy.tasks.evaluate_rules")
+ def test_evaluate_all_products_excludes_locked_by_default(self, mock_evaluate, mock_fire):
+ # Regression: previously called .exclude_locked() on DataspacedQuerySet which lacks that
+ # method. Now uses .exclude(configuration_status__is_locked=True) inline.
+ mock_evaluate.return_value = ([], 0)
+ active_product = make_product(self.dataspace)
+ locked_status = make_product_status(self.dataspace, is_locked=True)
+ locked_product = make_product(self.dataspace, configuration_status=locked_status)
+ mock_evaluate.reset_mock()
+
+ evaluate_all_products_rules_task()
+
+ evaluated_products = [c[0][0] for c in mock_evaluate.call_args_list]
+ self.assertIn(active_product, evaluated_products)
+ self.assertNotIn(locked_product, evaluated_products)
+
+ @patch("policy.tasks.fire_policy_webhooks")
+ @patch("policy.tasks.evaluate_rules")
+ def test_evaluate_all_products_includes_locked_when_requested(self, mock_evaluate, mock_fire):
+ mock_evaluate.return_value = ([], 0)
+ locked_status = make_product_status(self.dataspace, is_locked=True)
+ locked_product = make_product(self.dataspace, configuration_status=locked_status)
+ mock_evaluate.reset_mock()
+
+ evaluate_all_products_rules_task(include_locked=True)
+
+ evaluated_products = [c[0][0] for c in mock_evaluate.call_args_list]
+ self.assertIn(locked_product, evaluated_products)
+
+ @patch("policy.tasks.fire_policy_webhooks")
+ @patch("policy.tasks.evaluate_rules")
+ def test_evaluate_all_products_filters_by_uuids(self, mock_evaluate, mock_fire):
+ mock_evaluate.return_value = ([], 0)
+ product_a = make_product(self.dataspace)
+ product_b = make_product(self.dataspace)
+ mock_evaluate.reset_mock()
+
+ evaluate_all_products_rules_task(product_uuids=[product_a.uuid])
+
+ evaluated_products = [c[0][0] for c in mock_evaluate.call_args_list]
+ self.assertIn(product_a, evaluated_products)
+ self.assertNotIn(product_b, evaluated_products)
+
+ @patch("policy.tasks.fire_policy_webhooks")
+ @patch("policy.tasks.evaluate_rules")
+ def test_evaluate_all_products_uuid_filter_ignores_locked_exclusion(
+ self, mock_evaluate, mock_fire
+ ):
+ mock_evaluate.return_value = ([], 0)
+ locked_status = make_product_status(self.dataspace, is_locked=True)
+ locked_product = make_product(self.dataspace, configuration_status=locked_status)
+ mock_evaluate.reset_mock()
+
+ evaluate_all_products_rules_task(product_uuids=[locked_product.uuid])
+
+ evaluated_products = [c[0][0] for c in mock_evaluate.call_args_list]
+ self.assertIn(locked_product, evaluated_products)
diff --git a/product_portfolio/admin.py b/product_portfolio/admin.py
index c9624c07..7e9a9f6f 100644
--- a/product_portfolio/admin.py
+++ b/product_portfolio/admin.py
@@ -384,7 +384,7 @@ class ProductAdmin(
ProductPackageInline,
]
form = ProductAdminForm
- actions = []
+ actions = ["evaluate_policy_rules"]
actions_to_remove = ["copy_to", "compare_with", "delete_selected"]
navigation_buttons = True
activity_log = False
@@ -395,6 +395,16 @@ class ProductAdmin(
awesomplete_data = {"primary_language": PROGRAMMING_LANGUAGES}
readonly_fields = DataspacedAdmin.readonly_fields + ("get_feature_datalist",)
+ def evaluate_policy_rules(self, request, queryset):
+ from policy.tasks import evaluate_product_rules_task
+
+ count = queryset.count()
+ for product in queryset:
+ evaluate_product_rules_task.delay(product_uuid=product.uuid)
+ self.message_user(request, f"Policy rules evaluation enqueued for {count} product(s).")
+
+ evaluate_policy_rules.short_description = _("Evaluate policy rules")
+
def get_feature_datalist(self, obj):
if obj.pk:
return obj.get_feature_datalist()
diff --git a/product_portfolio/api.py b/product_portfolio/api.py
index c8d9998f..af6602b4 100644
--- a/product_portfolio/api.py
+++ b/product_portfolio/api.py
@@ -48,6 +48,7 @@
from product_portfolio.models import ProductComponent
from product_portfolio.models import ProductDependency
from product_portfolio.models import ProductPackage
+from product_portfolio.models import ProductPolicyViolation
from product_portfolio.models import ScanCodeProject
from vulnerabilities.api import VulnerabilityAnalysisSerializer
@@ -343,6 +344,25 @@ class Meta:
)
+class ProductPolicyViolationSerializer(serializers.ModelSerializer):
+ rule_label = serializers.ReadOnlyField()
+ rule_description = serializers.ReadOnlyField()
+ rule_severity = serializers.ReadOnlyField()
+
+ class Meta:
+ model = ProductPolicyViolation
+ fields = (
+ "rule_type",
+ "rule_label",
+ "rule_description",
+ "rule_severity",
+ "violation_count",
+ "detected_date",
+ "resolved",
+ "resolved_date",
+ )
+
+
class ProductViewSet(
ObjectPermissionsMixin,
SendAboutFilesMixin,
@@ -413,6 +433,14 @@ def imports(self, request, uuid):
projects_data = ScanCodeProjectSerializer(scancode_projects, many=True).data
return Response(projects_data)
+ @action(detail=True, url_path="policy_violations")
+ def policy_violations(self, request, uuid):
+ """List active policy violations for this product, with rule details and counts."""
+ product = self.get_object()
+ violations = product.policy_violations.filter(resolved=False)
+ serializer = ProductPolicyViolationSerializer(violations, many=True)
+ return Response(serializer.data)
+
@action(detail=True, methods=["post"], serializer_class=LoadSBOMsFormSerializer)
def load_sboms(self, request, *args, **kwargs):
"""
diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py
index 98f6f369..359f422f 100644
--- a/product_portfolio/filters.py
+++ b/product_portfolio/filters.py
@@ -31,11 +31,13 @@
from dje.widgets import DropDownRightWidget
from dje.widgets import DropDownWidget
from license_library.models import License
+from policy.rules import RULE_REGISTRY
from product_portfolio.models import CodebaseResource
from product_portfolio.models import Product
from product_portfolio.models import ProductComponent
from product_portfolio.models import ProductDependency
from product_portfolio.models import ProductPackage
+from product_portfolio.models import ProductPolicyViolation
from product_portfolio.models import ProductStatus
from vulnerabilities.filters import ScoreRangeFilter
from vulnerabilities.models import RISK_SCORE_RANGES
@@ -149,6 +151,10 @@ class ProductFilterSet(DataspacedFilterSet):
label=_("License issues"),
method="filter_license_compliance_issues",
)
+ policy_violations = django_filters.BooleanFilter(
+ label=_("Policy violations"),
+ method="filter_policy_violations",
+ )
class Meta:
model = Product
@@ -189,6 +195,16 @@ def filter_license_compliance_issues(self, queryset, name, value):
condition = Exists(has_alert)
return queryset.filter(condition if value else ~condition)
+ def filter_policy_violations(self, queryset, name, value):
+ if value is None:
+ return queryset
+ has_violation = ProductPolicyViolation.objects.filter(
+ product_id=OuterRef("pk"),
+ resolved=False,
+ )
+ condition = Exists(has_violation)
+ return queryset.filter(condition if value else ~condition)
+
class BaseProductRelationFilterSet(DataspacedFilterSet):
field_name_prefix = None
@@ -392,6 +408,7 @@ 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
@@ -407,6 +424,13 @@ 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/0018_productpolicyviolation.py b/product_portfolio/migrations/0018_productpolicyviolation.py
new file mode 100644
index 00000000..9b38d420
--- /dev/null
+++ b/product_portfolio/migrations/0018_productpolicyviolation.py
@@ -0,0 +1,37 @@
+# Generated by Django 6.0.6 on 2026-07-15 08:25
+
+import django.db.models.deletion
+import dje.models
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dje', '0016_dataspaceconfiguration_policy_rules_config'),
+ ('product_portfolio', '0017_scancodeproject_import_options'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ProductPolicyViolation',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')),
+ ('violation_count', models.PositiveIntegerField(default=0, help_text='Number of objects currently violating the rule.')),
+ ('detected_date', models.DateTimeField(auto_now_add=True, help_text='The date and time when this violation was first detected.')),
+ ('last_checked', models.DateTimeField(auto_now=True, help_text='The date and time of the last evaluation.')),
+ ('resolved', models.BooleanField(default=False, help_text='Indicates whether this violation has been resolved.')),
+ ('resolved_date', models.DateTimeField(blank=True, help_text='The date and time when this violation was resolved.', null=True)),
+ ('rule_type', models.CharField(help_text='The rule type from the rule registry that triggered this violation.', max_length=50)),
+ ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')),
+ ('product', models.ForeignKey(help_text='The product in the context of which this violation was detected.', on_delete=django.db.models.deletion.CASCADE, related_name='policy_violations', to='product_portfolio.product')),
+ ],
+ options={
+ 'ordering': ['-detected_date'],
+ 'unique_together': {('dataspace', 'uuid'), ('rule_type', 'product')},
+ },
+ bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model),
+ ),
+ ]
diff --git a/product_portfolio/models.py b/product_portfolio/models.py
index 9fb8e6c9..550623dc 100644
--- a/product_portfolio/models.py
+++ b/product_portfolio/models.py
@@ -24,6 +24,7 @@
from django.db.models import Max
from django.db.models import OuterRef
from django.db.models import Q
+from django.db.models import Subquery
from django.db.models import Value
from django.db.models import When
from django.db.models.functions import Coalesce
@@ -56,6 +57,8 @@
from dje.validators import generic_uri_validator
from dje.validators import validate_url_segment
from dje.validators import validate_version
+from policy.models import AbstractPolicyViolation
+from policy.rules import RULE_REGISTRY
from vulnerabilities.fetch import fetch_for_packages
from vulnerabilities.models import AffectedByVulnerabilityMixin
from vulnerabilities.models import AffectedByVulnerabilityRelationship
@@ -139,6 +142,9 @@ class Meta(BaseStatusMixin.Meta):
class ProductQuerySet(DataspacedQuerySet):
+ def exclude_locked(self):
+ return self.exclude(configuration_status__is_locked=True)
+
def with_risk_threshold(self):
return self.annotate(
risk_threshold=Coalesce(
@@ -240,6 +246,20 @@ def with_has_vulnerable_packages(self):
has_vulnerable_packages=Exists(vulnerable_productpackage_qs),
)
+ def with_policy_violation_count(self):
+ subquery = (
+ ProductPolicyViolation.objects.filter(
+ product=OuterRef("pk"),
+ resolved=False,
+ )
+ .values("product")
+ .annotate(violation_count=models.Count("id"))
+ .values("violation_count")
+ )
+ return self.annotate(
+ policy_violation_count=Subquery(subquery, output_field=models.IntegerField()),
+ )
+
class ProductSecuredManager(DataspacedManager):
"""
@@ -286,7 +306,7 @@ def get_queryset(
).scope(user.dataspace)
if exclude_locked:
- queryset = queryset.exclude(configuration_status__is_locked=True)
+ queryset = queryset.exclude_locked()
if include_inactive:
return queryset
@@ -467,6 +487,9 @@ def get_export_license_compliance_url(self):
def get_export_security_compliance_url(self):
return self.get_url("export_security_compliance")
+ def get_evaluate_policy_rules_url(self):
+ return self.get_url("evaluate_policy_rules")
+
@property
def cyclonedx_bom_ref(self):
return str(self.uuid)
@@ -1901,3 +1924,47 @@ def save(self, *args, **kwargs):
"The 'for_package' cannot be the same as 'resolved_to_package'."
)
super().save(*args, **kwargs)
+
+
+class ProductPolicyViolationQuerySet(ProductSecuredQuerySet):
+ def unresolved(self):
+ return self.filter(resolved=False)
+
+
+class ProductPolicyViolation(DataspacedModel, AbstractPolicyViolation):
+ """Concrete policy violation scoped to a product."""
+
+ product = models.ForeignKey(
+ to="product_portfolio.Product",
+ on_delete=models.CASCADE,
+ related_name="policy_violations",
+ help_text=_("The product in the context of which this violation was detected."),
+ )
+ rule_type = models.CharField(
+ max_length=50,
+ help_text=_("The rule type from the rule registry that triggered this violation."),
+ )
+
+ objects = DataspacedManager.from_queryset(ProductPolicyViolationQuerySet)()
+
+ class Meta:
+ unique_together = (("dataspace", "uuid"), ("rule_type", "product"))
+ ordering = ["-detected_date"]
+
+ def __str__(self):
+ return f"{self.rule_type} / {self.product}: {self.violation_count} violation(s)"
+
+ @property
+ def rule_label(self):
+ handler = RULE_REGISTRY.get(self.rule_type)
+ return handler.label if handler else self.rule_type
+
+ @property
+ def rule_description(self):
+ handler = RULE_REGISTRY.get(self.rule_type)
+ return handler.description if handler else ""
+
+ @property
+ def rule_severity(self):
+ handler = RULE_REGISTRY.get(self.rule_type)
+ return handler.severity if handler else "warning"
diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html
index bd258e85..cda97072 100644
--- a/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html
+++ b/product_portfolio/templates/product_portfolio/compliance/compliance_dashboard.html
@@ -68,36 +68,42 @@
-
{% trans "Security issues" %}
-
- {{ products_with_critical_or_high }}
-
+
{% trans "License compliance" %}
+ {% if products_with_license_issues %}
+
+ {{ products_with_license_issues }}
+
+ {% else %}
+
+ {{ products_with_license_issues }}
+
+ {% endif %}
- {% if products_with_critical_or_high %}
- {% trans "products with critical/high vulnerabilities" %}
+ {% if products_with_license_issues %}
+ {% trans "products with license compliance alerts" %}
{% else %}
- {% trans "No critical or high vulnerabilities" %}
+ {% trans "All products within policy" %}
{% endif %}
@@ -127,8 +133,8 @@
{% trans "Product" %}
{% trans "Packages" %}
+ {% trans "Policy violations" %}
{% trans "License compliance" %}
- {% trans "Security compliance" %}
{% trans "Vulnerabilities" %}
@@ -146,6 +152,15 @@
{{ product.package_count|intcomma }}
+
+ {% if product.policy_violation_count %}
+
+ {{ product.policy_violation_count }} {% trans "rule" %}{{ product.policy_violation_count|pluralize }} {% trans "triggered" %}
+
+ {% else %}
+ {% trans "None" %}
+ {% endif %}
+
{% if product.license_error_count %}
@@ -161,19 +176,6 @@
-
- {% if product.max_risk_level == "critical" %}
- {% trans "Critical" %}
- {% elif product.max_risk_level == "high" %}
- {% trans "High" %}
- {% elif product.max_risk_level == "medium" %}
- {% trans "Medium" %}
- {% elif product.max_risk_level == "low" %}
- {% trans "Low" %}
- {% else %}
- {% trans "OK" %}
- {% endif %}
-
{% if product.risk_threshold and product.vulnerability_count %}
diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html
index 92c0fe29..550bc451 100644
--- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html
+++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html
@@ -1,5 +1,116 @@
{% load i18n %}
{% url product.get_absolute_url as product_url %}
+
+{% if policy_violations %}
+
+
+
+
+
+
{% trans "Policy violations" %}
+
+
+
+
+
+ {% if has_change_permission %}
+
+ {% endif %}
+
+ {{ policy_violation_count }} {% trans "active" %}
+
+
+
+
+
+
+
+
+
+ {% for rule in all_rules %}
+
+
+ {% if rule.severity == "error" %}
+
{{ rule.label }}
+ {% else %}
+
{{ rule.label }}
+ {% endif %}
+
{{ rule.description }}
+
+
+ {% if not rule.is_active %}
+ {% trans "Disabled" %}
+ {% elif rule.is_violated %}
+ {% if rule.severity == "error" %}
+ {% trans "Triggered" %}
+ {% else %}
+ {% trans "Triggered" %}
+ {% endif %}
+ {% else %}
+ {% trans "OK" %}
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+ {% trans "Rule" %}
+ {% trans "Description" %}
+ {% trans "In violation" %}
+ {% trans "Detected" %}
+
+
+
+ {% for violation in policy_violations %}
+
+
+ {% if violation.rule_severity == "error" %}
+ {{ violation.rule_label }}
+ {% else %}
+ {{ violation.rule_label }}
+ {% endif %}
+
+ {{ violation.rule_description }}
+
+
+ {{ violation.violation_count }}
+
+
+ {{ violation.detected_date|date:"N j, Y" }}
+
+ {% endfor %}
+
+
+
+
+
+
+{% endif %}
+
{# License panel #}
@@ -170,38 +281,48 @@
{% trans "Security compliance" %}
{% else %}
{{ vulnerability_count }}
{% trans "vulnerabilit" %}{{ vulnerability_count|pluralize:"y,ies" }}
- — {% trans "no risk threshold set" %}
+ - {% 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.advisory_id }}
-
-
{{ vulnerability.summary|truncatechars:70 }}
-
- {% empty %}
-
-
- {% trans "No known vulnerabilities" %}
-
- {% endfor %}
+
+
+ {% 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.advisory_id }}
+
+
+ {{ vulnerability.summary|truncatechars:70 }}
+
+ {% empty %}
+
+
+
+ {% trans "No known vulnerabilities" %}
+
+
+ {% endfor %}
+
+
{# View all link #}
{% if vulnerabilities|length < vulnerability_count %}
diff --git a/product_portfolio/templates/product_portfolio/compliance/metric_cards.html b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html
index 05a4ecba..14bd04e8 100644
--- a/product_portfolio/templates/product_portfolio/compliance/metric_cards.html
+++ b/product_portfolio/templates/product_portfolio/compliance/metric_cards.html
@@ -1,21 +1,22 @@
{% load i18n humanize %}
-
+
-
{% trans "Total packages" %}
-
{{ total_packages|intcomma }}
-
+ {% endif %}
-
+
{% trans "License compliance" %}
@@ -32,7 +33,7 @@
-
+
{% trans "License coverage" %}
@@ -49,7 +50,7 @@
-
+
{% trans "Vulnerabilities" %}
{% if vulnerability_count == 0 %}
@@ -63,14 +64,18 @@
{% elif risk_threshold_number %}
{{ above_threshold_count }}
- {% trans "of" %} {{ vulnerability_count }} {% trans "above threshold of" %} {{ risk_threshold_number }}
+
+ {% 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 %}
+
+ {% 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 %}
diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py
index ce43a34e..051b490f 100644
--- a/product_portfolio/tests/test_api.py
+++ b/product_portfolio/tests/test_api.py
@@ -43,6 +43,7 @@
from product_portfolio.models import ProductComponent
from product_portfolio.models import ProductItemPurpose
from product_portfolio.models import ProductPackage
+from product_portfolio.models import ProductPolicyViolation
from product_portfolio.models import ProductRelationStatus
from product_portfolio.models import ProductStatus
from product_portfolio.models import ScanCodeProject
@@ -717,6 +718,48 @@ def test_api_product_endpoint_manage_permissions_action(self):
self.assertIn("errors", response.data)
+ def test_api_product_endpoint_policy_violations_action(self):
+ url = reverse("api_v2:product-policy-violations", args=[self.product1.uuid])
+
+ self.client.login(username=self.base_user.username, password="secret")
+ response = self.client.get(url)
+ self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code)
+
+ add_perm(self.base_user, "add_product")
+ assign_perm("view_product", self.base_user, self.product1)
+
+ response = self.client.get(url)
+ self.assertEqual(status.HTTP_200_OK, response.status_code)
+ self.assertEqual([], response.data)
+
+ violation = ProductPolicyViolation.objects.create(
+ product=self.product1,
+ dataspace=self.dataspace,
+ rule_type="usage_policy_error",
+ violation_count=5,
+ )
+
+ response = self.client.get(url)
+ self.assertEqual(status.HTTP_200_OK, response.status_code)
+ self.assertEqual(1, len(response.data))
+ entry = response.data[0]
+ self.assertEqual("usage_policy_error", entry["rule_type"])
+ self.assertEqual(5, entry["violation_count"])
+ self.assertFalse(entry["resolved"])
+ self.assertIsNone(entry["resolved_date"])
+ self.assertIn("rule_label", entry)
+ self.assertIn("rule_description", entry)
+ self.assertIn("rule_severity", entry)
+ self.assertIn("detected_date", entry)
+
+ violation.resolved = True
+ violation.save()
+
+ response = self.client.get(url)
+ self.assertEqual(status.HTTP_200_OK, response.status_code)
+ self.assertEqual([], response.data)
+
+
class ProductRelatedAPITestCase(TestCase):
def setUp(self):
self.dataspace = Dataspace.objects.create(name="nexB")
diff --git a/product_portfolio/urls.py b/product_portfolio/urls.py
index 1c84b51f..8231594a 100644
--- a/product_portfolio/urls.py
+++ b/product_portfolio/urls.py
@@ -42,6 +42,7 @@
from product_portfolio.views import check_package_version_ajax_view
from product_portfolio.views import delete_scan_htmx_view
from product_portfolio.views import edit_productrelation_ajax_view
+from product_portfolio.views import evaluate_policy_rules_view
from product_portfolio.views import import_from_scan_view
from product_portfolio.views import import_packages_from_scancodeio_view
from product_portfolio.views import improve_packages_from_purldb_view
@@ -129,6 +130,7 @@ def product_path(path_segment, view):
*product_path("add_customcomponent_ajax", add_customcomponent_ajax_view),
*product_path("vulnerability_analysis_form", vulnerability_analysis_form_view),
*product_path("scan_all_packages", scan_all_packages_view),
+ *product_path("evaluate_policy_rules", evaluate_policy_rules_view),
*product_path("improve_packages_from_purldb", improve_packages_from_purldb_view),
*product_path("about_files", ProductSendAboutFilesView.as_view()),
*product_path("export_spdx", ProductExportSPDXDocumentView.as_view()),
diff --git a/product_portfolio/views.py b/product_portfolio/views.py
index f8e50d5f..e6dd7173 100644
--- a/product_portfolio/views.py
+++ b/product_portfolio/views.py
@@ -112,6 +112,9 @@
from license_library.filters import LicenseFilterSet
from license_library.models import License
from license_library.models import LicenseAssignedTag
+from policy.engine import evaluate_rules
+from policy.engine import get_effective_config
+from policy.rules import RULE_REGISTRY
from product_portfolio.filters import CodebaseResourceFilterSet
from product_portfolio.filters import DependencyFilterSet
from product_portfolio.filters import ProductComponentFilterSet
@@ -422,7 +425,10 @@ def tab_terms(self):
if self.object.notice_text:
notice_field = self.get_tab_fields([TabField("notice_text")])[0]
- tab_data["fields"].append(notice_field)
+ if tab_data is None:
+ tab_data = {"fields": [notice_field]}
+ else:
+ tab_data["fields"].append(notice_field)
return tab_data
@@ -2063,6 +2069,22 @@ def scan_all_packages_view(request, dataspace, name, version=""):
return redirect(product)
+@require_POST
+@login_required
+def evaluate_policy_rules_view(request, dataspace, name, version=""):
+ guarded_qs = Product.objects.get_queryset(request.user, perms="change_product")
+ product = get_object_or_404(
+ guarded_qs,
+ name=unquote_plus(name),
+ version=unquote_plus(version),
+ dataspace__name=dataspace,
+ )
+
+ evaluate_rules(product)
+
+ return HttpResponse(headers={"HX-Refresh": "true"})
+
+
@login_required
def import_from_scan_view(request, dataspace, name, version=""):
"""
@@ -2766,12 +2788,15 @@ def get_context_data(self, **kwargs):
product = self.object
productpackages = product.productpackages.all()
licenses = License.objects.filter(productpackage__in=productpackages)
+ user_perms = guardian_get_perms(self.request.user, product)
context.update(
{
**self.get_package_compliance_context(productpackages),
**self.get_license_compliance_context(licenses),
**self.get_security_compliance_context(product),
+ **self.get_policy_compliance_context(product),
+ "has_change_permission": "change_product" in user_perms,
}
)
@@ -2842,6 +2867,27 @@ def get_license_compliance_context(licenses, distribution_limit=10):
"remaining_license_count": max(0, len(license_distribution) - distribution_limit),
}
+ @staticmethod
+ def get_policy_compliance_context(product):
+ policy_violations = product.policy_violations.filter(resolved=False).order_by("rule_type")
+ violated_rule_types = {violation.rule_type for violation in policy_violations}
+ all_rules = [
+ {
+ "label": handler.label,
+ "description": handler.description,
+ "rule_type": rule_type,
+ "severity": handler.severity,
+ "is_active": get_effective_config(rule_type, product.dataspace)["is_active"],
+ "is_violated": rule_type in violated_rule_types,
+ }
+ for rule_type, handler in RULE_REGISTRY.items()
+ ]
+ return {
+ "policy_violations": policy_violations,
+ "policy_violation_count": policy_violations.count(),
+ "all_rules": all_rules,
+ }
+
@staticmethod
def get_security_compliance_context(product, display_limit=10):
risk_threshold = product.get_vulnerabilities_risk_threshold()
@@ -3042,36 +3088,40 @@ class ComplianceDashboardView(LoginRequiredMixin, ExportComplianceMixin, Dataspa
"package_count": "Packages",
"license_error_count": "License errors",
"license_warning_count": "License warnings",
- "max_risk_level": "Max risk level",
"risk_threshold": "Risk threshold",
"critical_count": "Critical",
"high_count": "High",
"medium_count": "Medium",
"low_count": "Low",
"vulnerability_count": "Total vulnerabilities",
+ "policy_violation_count": "Policy violations",
}
def get_queryset(self):
return (
get_viewable_products(self.request.user)
.with_compliance_data()
- .with_max_risk_level()
.with_has_vulnerable_packages()
+ .with_policy_violation_count()
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
products = self.object_list
- products_with_issues = products.with_compliance_issues().count()
+ products_with_issues = products.filter(
+ Q(license_error_count__gt=0)
+ | Q(license_warning_count__gt=0)
+ | Q(critical_count__gt=0)
+ | Q(high_count__gt=0)
+ | Q(policy_violation_count__gt=0)
+ ).count()
products_with_license_issues = products.filter(
Q(license_error_count__gt=0) | Q(license_warning_count__gt=0)
).count()
- products_with_critical_or_high = products.filter(
- Q(critical_count__gt=0) | Q(high_count__gt=0)
- ).count()
+ products_with_policy_violations = products.filter(policy_violation_count__gt=0).count()
totals = products.aggregate(
total_vulnerabilities=Sum("vulnerability_count"),
@@ -3086,7 +3136,7 @@ def get_context_data(self, **kwargs):
"total_products": context["paginator"].count,
"products_with_issues": products_with_issues,
"products_with_license_issues": products_with_license_issues,
- "products_with_critical_or_high": products_with_critical_or_high,
+ "products_with_policy_violations": products_with_policy_violations,
"total_vulnerabilities": totals["total_vulnerabilities"] or 0,
"total_critical": totals["total_critical"] or 0,
"total_high": totals["total_high"] or 0,