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 "License issues" %}
- {% if products_with_license_issues %} - - {{ products_with_license_issues }} +
{% trans "Policy violations" %}
+ {% if products_with_policy_violations %} +
+ {{ products_with_policy_violations }} {% else %}
- {{ products_with_license_issues }} + {{ products_with_policy_violations }}
{% endif %}
- {% if products_with_license_issues %} - {% trans "products with policy violations" %} + {% if products_with_policy_violations %} + {% trans "products with active rule violations" %} {% else %} - {% trans "All products within policy" %} + {% trans "No active policy violations" %} {% endif %}
-
{% 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 @@

{% trans "OK" %} {% endif %} - - {% 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 %} +
+ {% csrf_token %} + +
+ {% endif %} + + {{ policy_violation_count }} {% trans "active" %} + +
+
+ + + + + + + + + + + + + {% for violation in policy_violations %} + + + + + + + {% endfor %} + +
{% trans "Rule" %}{% trans "Description" %}{% trans "In violation" %}{% trans "Detected" %}
+ {% 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" }}
+
+
+
+ +{% 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 %} + + + + + + {% empty %} + + + + {% endfor %} + +
+ + {% 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 }}
+ + {% trans "No known vulnerabilities" %} +
{# 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 "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,