From a7d7929e2d1ddca7db922d0bc3fd0aba4191246f Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 30 Jul 2026 17:47:31 +0400 Subject: [PATCH 01/90] add vulnerabilities triage module Signed-off-by: tdruez --- dejacode/settings.py | 1 + vulnerabilities/triage/__init__.py | 0 vulnerabilities/triage/apps.py | 14 ++++++++++++++ 3 files changed, 15 insertions(+) create mode 100644 vulnerabilities/triage/__init__.py create mode 100644 vulnerabilities/triage/apps.py diff --git a/dejacode/settings.py b/dejacode/settings.py index 7c9af8c3..708678f3 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -345,6 +345,7 @@ def gettext_noop(s): "policy", "notification", "vulnerabilities", + "vulnerabilities.triage", ] EXTRA_APPS = env.list("EXTRA_APPS", default=[]) diff --git a/vulnerabilities/triage/__init__.py b/vulnerabilities/triage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vulnerabilities/triage/apps.py b/vulnerabilities/triage/apps.py new file mode 100644 index 00000000..7465d501 --- /dev/null +++ b/vulnerabilities/triage/apps.py @@ -0,0 +1,14 @@ +# +# 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 AppConfig + + +class TriageConfig(AppConfig): + name = "vulnerabilities.triage" + label = "vulnerabilities_triage" From b7e161f1b39f621bc351c2af4fb5171a2e8a8f73 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 30 Jul 2026 18:30:22 +0400 Subject: [PATCH 02/90] add TriageRuleset model Signed-off-by: tdruez --- .../triage/migrations/0001_initial.py | 36 ++++++++++++++ vulnerabilities/triage/migrations/__init__.py | 0 vulnerabilities/triage/models.py | 49 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 vulnerabilities/triage/migrations/0001_initial.py create mode 100644 vulnerabilities/triage/migrations/__init__.py create mode 100644 vulnerabilities/triage/models.py diff --git a/vulnerabilities/triage/migrations/0001_initial.py b/vulnerabilities/triage/migrations/0001_initial.py new file mode 100644 index 00000000..9a33dc67 --- /dev/null +++ b/vulnerabilities/triage/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.6 on 2026-07-30 14:29 + +import django.db.models.deletion +import dje.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dje', '0016_dataspaceconfiguration_policy_rules_config'), + ] + + operations = [ + migrations.CreateModel( + name='TriageRuleset', + 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')), + ('name', models.CharField(help_text='Short name identifying this triage ruleset within the dataspace.', max_length=100)), + ('description', models.TextField(blank=True, help_text='Optional description of the purpose or scope of this ruleset.')), + ('precedence', models.PositiveIntegerField(default=100, help_text='When multiple rulesets are assigned to a product and produce different actions, the one with the highest precedence takes effect.')), + ('enabled', models.BooleanField(default=True, help_text='Activate or deactivate this ruleset without deleting it.')), + ('rules_config', models.JSONField(blank=True, default=dict, help_text='Active rules for this ruleset, keyed by rule type. Each entry may include is_active, threshold, and parameters.')), + ('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')), + ], + options={ + 'ordering': ('-precedence', 'name'), + 'unique_together': {('dataspace', 'name'), ('dataspace', 'uuid')}, + }, + bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), + ), + ] diff --git a/vulnerabilities/triage/migrations/__init__.py b/vulnerabilities/triage/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py new file mode 100644 index 00000000..8ef7f306 --- /dev/null +++ b/vulnerabilities/triage/models.py @@ -0,0 +1,49 @@ +# +# 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.db import models +from django.utils.translation import gettext_lazy as _ + +from dje.models import DataspacedModel + + +class TriageRuleset(DataspacedModel): + name = models.CharField( + max_length=100, + help_text=_("Short name identifying this triage ruleset."), + ) + description = models.TextField( + blank=True, + help_text=_("Optional description of the purpose or scope of this ruleset."), + ) + precedence = models.PositiveIntegerField( + default=100, + help_text=_( + "When multiple rulesets are assigned to a product and produce different" + " actions, the one with the highest precedence takes effect." + ), + ) + enabled = models.BooleanField( + default=True, + help_text=_("Activate or deactivate this ruleset without deleting it."), + ) + rules_config = models.JSONField( + default=dict, + blank=True, + help_text=_( + "Active rules for this ruleset, keyed by rule type." + " Each entry may include is_active, threshold, and parameters." + ), + ) + + class Meta: + unique_together = (("dataspace", "name"), ("dataspace", "uuid")) + ordering = ("-precedence", "name") + + def __str__(self): + return self.name From 20966b0ca387c0a97381cd0566a8c972ffb0d80a Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 5 Aug 2026 15:33:12 +0400 Subject: [PATCH 03/90] add 2 vulnrability triage rules Signed-off-by: tdruez --- vulnerabilities/triage/rules.py | 69 +++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 vulnerabilities/triage/rules.py diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py new file mode 100644 index 00000000..26f281db --- /dev/null +++ b/vulnerabilities/triage/rules.py @@ -0,0 +1,69 @@ +# +# 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 + +from policy.rules import BaseRule + +ACTION_UPGRADE = "upgrade" + + +class BaseTriageRule(BaseRule): + """Base class for vulnerability triage rule handlers.""" + + action = None + timeline_days = None + + +class CriticalVulnerabilityTriageRule(BaseTriageRule): + rule_type = "critical_vulnerability" + label = "Critical Vulnerability" + description = "Packages with at least one critical-severity vulnerability (risk score >= 8.0)." + severity = "error" + action = ACTION_UPGRADE + timeline_days = 1 + + def count_violations(self, product, threshold, parameters): + ProductPackage = apps.get_model("product_portfolio", "productpackage") + count = ( + ProductPackage.objects.filter( + product=product, + package__affected_by_vulnerabilities__risk_level="critical", + ) + .distinct() + .count() + ) + return count if count > threshold else 0 + + +class ExploitedVulnerabilityTriageRule(BaseTriageRule): + rule_type = "exploited_vulnerability" + label = "Exploited Vulnerability" + description = "Packages with vulnerabilities for which known exploits are available." + severity = "error" + action = ACTION_UPGRADE + timeline_days = 1 + + def count_violations(self, product, threshold, parameters): + ProductPackage = apps.get_model("product_portfolio", "productpackage") + # exploitability == 2.0 means known exploits are available (vs 0.5 none, 1.0 potential) + count = ( + ProductPackage.objects.filter( + product=product, + package__affected_by_vulnerabilities__exploitability=2.0, + ) + .distinct() + .count() + ) + return count if count > threshold else 0 + + +RULE_REGISTRY = { + CriticalVulnerabilityTriageRule.rule_type: CriticalVulnerabilityTriageRule(), + ExploitedVulnerabilityTriageRule.rule_type: ExploitedVulnerabilityTriageRule(), +} From c1e524e8ac855a61763a9987f54c91e9a3954c3c Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 5 Aug 2026 15:38:13 +0400 Subject: [PATCH 04/90] add verbose name Signed-off-by: tdruez --- vulnerabilities/triage/apps.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vulnerabilities/triage/apps.py b/vulnerabilities/triage/apps.py index 7465d501..a7075c77 100644 --- a/vulnerabilities/triage/apps.py +++ b/vulnerabilities/triage/apps.py @@ -7,8 +7,10 @@ # from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ class TriageConfig(AppConfig): name = "vulnerabilities.triage" label = "vulnerabilities_triage" + verbose_name = _("Vulnerabilities Triage") From a84c095d78ab8d2923eb2dbd8b958712881cff0d Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 5 Aug 2026 15:40:11 +0400 Subject: [PATCH 05/90] add TriageRulesetAdmin to manage ruleset Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 53 ++++++++++++++++++++++ vulnerabilities/triage/forms.py | 79 +++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 vulnerabilities/triage/admin.py create mode 100644 vulnerabilities/triage/forms.py diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py new file mode 100644 index 00000000..73ad6667 --- /dev/null +++ b/vulnerabilities/triage/admin.py @@ -0,0 +1,53 @@ +# +# 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.contrib import admin + +from dje.admin import DataspacedAdmin +from dje.admin import dejacode_site +from vulnerabilities.triage.forms import TriageRulesetForm +from vulnerabilities.triage.models import TriageRuleset +from vulnerabilities.triage.rules import RULE_REGISTRY + + +@admin.register(TriageRuleset, site=dejacode_site) +class TriageRulesetAdmin(DataspacedAdmin): + form = TriageRulesetForm + list_display = ["name", "precedence", "enabled", "get_dataspace"] + list_filter = DataspacedAdmin.list_filter + ("enabled",) + search_fields = ["name"] + + def get_form(self, request, obj=None, change=False, **kwargs): + kwargs["fields"] = ["name", "description", "precedence", "enabled"] + return super().get_form(request, obj, change=change, **kwargs) + + def get_fieldsets(self, request, obj=None): + base_fieldsets = [ + ( + None, + {"fields": ["name", "description", "precedence", "enabled"]}, + ), + ] + if not obj: + return base_fieldsets + rule_fieldsets = [] + for rule_type, handler in RULE_REGISTRY.items(): + fields = [f"rule_{rule_type}_enabled", 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 base_fieldsets + rule_fieldsets diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py new file mode 100644 index 00000000..796c4145 --- /dev/null +++ b/vulnerabilities/triage/forms.py @@ -0,0 +1,79 @@ +# +# 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 import forms +from django.utils.translation import gettext_lazy as _ + +from dje.forms import DataspacedAdminForm +from vulnerabilities.triage.models import TriageRuleset +from vulnerabilities.triage.rules import RULE_REGISTRY + + +class TriageRulesetForm(DataspacedAdminForm): + class Meta: + model = TriageRuleset + fields = ["name", "description", "precedence", "enabled"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.add_rule_fields() + + def add_rule_fields(self): + if not self.instance.pk: + return + config = getattr(self.instance, "rules_config", {}) or {} + for rule_type, handler in RULE_REGISTRY.items(): + rule_config = config.get(rule_type, {}) + self.fields[f"rule_{rule_type}_enabled"] = forms.BooleanField( + label=f"Enable {handler.label}", + required=False, + initial=rule_config.get("is_active", False), + ) + 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_rules_config(self): + rules_config = {} + for rule_type, handler in RULE_REGISTRY.items(): + rule_config = {} + if self.cleaned_data.get(f"rule_{rule_type}_enabled"): + rule_config["is_active"] = True + 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: + rules_config[rule_type] = rule_config + return rules_config + + def save(self, commit=True): + self.instance.rules_config = self.build_rules_config() + return super().save(commit=commit) From 7de1361a2748bb0672b9b1bb52e565873b547cd0 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 5 Aug 2026 15:46:51 +0400 Subject: [PATCH 06/90] display enabled rules in change list Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 73ad6667..4f6afe4e 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -7,6 +7,8 @@ # from django.contrib import admin +from django.utils.html import escape +from django.utils.html import mark_safe from dje.admin import DataspacedAdmin from dje.admin import dejacode_site @@ -18,10 +20,24 @@ @admin.register(TriageRuleset, site=dejacode_site) class TriageRulesetAdmin(DataspacedAdmin): form = TriageRulesetForm - list_display = ["name", "precedence", "enabled", "get_dataspace"] + list_display = ["name", "get_enabled_rules", "precedence", "enabled", "get_dataspace"] list_filter = DataspacedAdmin.list_filter + ("enabled",) search_fields = ["name"] + @admin.display(description="Enabled rules") + def get_enabled_rules(self, obj): + enabled = [ + RULE_REGISTRY[rule_type].label + for rule_type, config in obj.rules_config.items() + if rule_type in RULE_REGISTRY and config.get("is_active") + ] + return mark_safe("
".join(escape(label) for label in enabled)) + + def get_changes_details(self, form): + model_field_names = {field.name for field in TriageRuleset._meta.get_fields()} + form.__dict__["changed_data"] = [f for f in form.changed_data if f in model_field_names] + return super().get_changes_details(form) + def get_form(self, request, obj=None, change=False, **kwargs): kwargs["fields"] = ["name", "description", "precedence", "enabled"] return super().get_form(request, obj, change=change, **kwargs) From ac493676ae4036532ddd4e9efd3c2c930ff34488 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 5 Aug 2026 18:01:53 +0400 Subject: [PATCH 07/90] simplify the rules implementation Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 43 ++++++++++++------- vulnerabilities/triage/forms.py | 39 ++--------------- ...ction_alter_triageruleset_name_and_more.py | 28 ++++++++++++ vulnerabilities/triage/models.py | 21 +++++++-- vulnerabilities/triage/rules.py | 19 ++------ 5 files changed, 78 insertions(+), 72 deletions(-) create mode 100644 vulnerabilities/triage/migrations/0002_triageruleset_action_alter_triageruleset_name_and_more.py diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 4f6afe4e..7e91d76a 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -19,6 +19,19 @@ @admin.register(TriageRuleset, site=dejacode_site) class TriageRulesetAdmin(DataspacedAdmin): + short_description = ( + "A Triage Ruleset is a named set of detection rules that, when their conditions" + " are met for a product, recommends a specific remediation action." + ) + + long_description = ( + "Each ruleset combines one or more rules (such as critical vulnerability detection" + " or exploited vulnerability detection) with a single action to recommend" + " (upgrade, apply patch, notify, etc.). Multiple rulesets can be assigned to a" + " product; when conditions overlap, the ruleset with the highest precedence takes" + " effect." + ) + form = TriageRulesetForm list_display = ["name", "get_enabled_rules", "precedence", "enabled", "get_dataspace"] list_filter = DataspacedAdmin.list_filter + ("enabled",) @@ -31,6 +44,8 @@ def get_enabled_rules(self, obj): for rule_type, config in obj.rules_config.items() if rule_type in RULE_REGISTRY and config.get("is_active") ] + if not enabled: + return "" return mark_safe("
".join(escape(label) for label in enabled)) def get_changes_details(self, form): @@ -39,31 +54,27 @@ def get_changes_details(self, form): return super().get_changes_details(form) def get_form(self, request, obj=None, change=False, **kwargs): - kwargs["fields"] = ["name", "description", "precedence", "enabled"] + kwargs["fields"] = ["name", "description", "action", "precedence", "enabled"] return super().get_form(request, obj, change=change, **kwargs) def get_fieldsets(self, request, obj=None): base_fieldsets = [ ( None, - {"fields": ["name", "description", "precedence", "enabled"]}, + {"fields": ["name", "description", "action", "precedence", "enabled"]}, ), ] if not obj: return base_fieldsets - rule_fieldsets = [] - for rule_type, handler in RULE_REGISTRY.items(): - fields = [f"rule_{rule_type}_enabled", 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",), - }, - ) + rule_fieldsets = [ + ( + handler.label, + { + "fields": [f"rule_{rule_type}_enabled"], + "description": handler.description, + "classes": ("grp-collapse grp-open",), + }, ) + for rule_type, handler in RULE_REGISTRY.items() + ] return base_fieldsets + rule_fieldsets diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py index 796c4145..e6e8153f 100644 --- a/vulnerabilities/triage/forms.py +++ b/vulnerabilities/triage/forms.py @@ -7,7 +7,6 @@ # from django import forms -from django.utils.translation import gettext_lazy as _ from dje.forms import DataspacedAdminForm from vulnerabilities.triage.models import TriageRuleset @@ -17,7 +16,7 @@ class TriageRulesetForm(DataspacedAdminForm): class Meta: model = TriageRuleset - fields = ["name", "description", "precedence", "enabled"] + fields = ["name", "description", "action", "precedence", "enabled"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -34,44 +33,12 @@ def add_rule_fields(self): required=False, initial=rule_config.get("is_active", False), ) - 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_rules_config(self): rules_config = {} - for rule_type, handler in RULE_REGISTRY.items(): - rule_config = {} + for rule_type in RULE_REGISTRY: if self.cleaned_data.get(f"rule_{rule_type}_enabled"): - rule_config["is_active"] = True - 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: - rules_config[rule_type] = rule_config + rules_config[rule_type] = {"is_active": True} return rules_config def save(self, commit=True): diff --git a/vulnerabilities/triage/migrations/0002_triageruleset_action_alter_triageruleset_name_and_more.py b/vulnerabilities/triage/migrations/0002_triageruleset_action_alter_triageruleset_name_and_more.py new file mode 100644 index 00000000..e5758aa0 --- /dev/null +++ b/vulnerabilities/triage/migrations/0002_triageruleset_action_alter_triageruleset_name_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.6 on 2026-08-05 11:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vulnerabilities_triage', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='triageruleset', + name='action', + field=models.CharField(blank=True, choices=[('upgrade', 'Upgrade Package'), ('apply_patch', 'Apply Patch'), ('forensic_analysis', 'Forensic Analysis'), ('reachability_analysis', 'Reachability Analysis'), ('change_config', 'Change Configuration'), ('replace_package', 'Replace Package'), ('notify', 'Notify'), ('create_request', 'Create DejaCode Request')], help_text="Action recommended when this ruleset's conditions are met.", max_length=50), + ), + migrations.AlterField( + model_name='triageruleset', + name='name', + field=models.CharField(help_text='Short name identifying this triage ruleset.', max_length=100), + ), + migrations.AlterField( + model_name='triageruleset', + name='rules_config', + field=models.JSONField(blank=True, default=dict, help_text='Active rules for this ruleset, keyed by rule type.'), + ), + ] diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index 8ef7f306..614795f1 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -13,6 +13,16 @@ class TriageRuleset(DataspacedModel): + class Action(models.TextChoices): + UPGRADE = "upgrade", _("Upgrade Package") + APPLY_PATCH = "apply_patch", _("Apply Patch") + FORENSIC_ANALYSIS = "forensic_analysis", _("Forensic Analysis") + REACHABILITY_ANALYSIS = "reachability_analysis", _("Reachability Analysis") + CHANGE_CONFIG = "change_config", _("Change Configuration") + REPLACE_PACKAGE = "replace_package", _("Replace Package") + NOTIFY = "notify", _("Notify") + CREATE_REQUEST = "create_request", _("Create DejaCode Request") + name = models.CharField( max_length=100, help_text=_("Short name identifying this triage ruleset."), @@ -21,6 +31,12 @@ class TriageRuleset(DataspacedModel): blank=True, help_text=_("Optional description of the purpose or scope of this ruleset."), ) + action = models.CharField( + max_length=50, + choices=Action.choices, + blank=True, + help_text=_("Action recommended when this ruleset's conditions are met."), + ) precedence = models.PositiveIntegerField( default=100, help_text=_( @@ -35,10 +51,7 @@ class TriageRuleset(DataspacedModel): rules_config = models.JSONField( default=dict, blank=True, - help_text=_( - "Active rules for this ruleset, keyed by rule type." - " Each entry may include is_active, threshold, and parameters." - ), + help_text=_("Active rules for this ruleset, keyed by rule type."), ) class Meta: diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index 26f281db..15c9ffbf 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -10,27 +10,19 @@ from policy.rules import BaseRule -ACTION_UPGRADE = "upgrade" - class BaseTriageRule(BaseRule): """Base class for vulnerability triage rule handlers.""" - action = None - timeline_days = None - class CriticalVulnerabilityTriageRule(BaseTriageRule): rule_type = "critical_vulnerability" label = "Critical Vulnerability" description = "Packages with at least one critical-severity vulnerability (risk score >= 8.0)." - severity = "error" - action = ACTION_UPGRADE - timeline_days = 1 def count_violations(self, product, threshold, parameters): ProductPackage = apps.get_model("product_portfolio", "productpackage") - count = ( + return ( ProductPackage.objects.filter( product=product, package__affected_by_vulnerabilities__risk_level="critical", @@ -38,21 +30,17 @@ def count_violations(self, product, threshold, parameters): .distinct() .count() ) - return count if count > threshold else 0 class ExploitedVulnerabilityTriageRule(BaseTriageRule): rule_type = "exploited_vulnerability" label = "Exploited Vulnerability" description = "Packages with vulnerabilities for which known exploits are available." - severity = "error" - action = ACTION_UPGRADE - timeline_days = 1 def count_violations(self, product, threshold, parameters): ProductPackage = apps.get_model("product_portfolio", "productpackage") - # exploitability == 2.0 means known exploits are available (vs 0.5 none, 1.0 potential) - count = ( + # exploitability == 2.0 means known exploits are available + return ( ProductPackage.objects.filter( product=product, package__affected_by_vulnerabilities__exploitability=2.0, @@ -60,7 +48,6 @@ def count_violations(self, product, threshold, parameters): .distinct() .count() ) - return count if count > threshold else 0 RULE_REGISTRY = { From 7f4a02d2aaed5afe740598ff453853d44f3a3e06 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 5 Aug 2026 18:19:35 +0400 Subject: [PATCH 08/90] display rules on add form Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 2 -- vulnerabilities/triage/forms.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 7e91d76a..581bd6cc 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -64,8 +64,6 @@ def get_fieldsets(self, request, obj=None): {"fields": ["name", "description", "action", "precedence", "enabled"]}, ), ] - if not obj: - return base_fieldsets rule_fieldsets = [ ( handler.label, diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py index e6e8153f..ef0702b8 100644 --- a/vulnerabilities/triage/forms.py +++ b/vulnerabilities/triage/forms.py @@ -23,8 +23,6 @@ def __init__(self, *args, **kwargs): self.add_rule_fields() def add_rule_fields(self): - if not self.instance.pk: - return config = getattr(self.instance, "rules_config", {}) or {} for rule_type, handler in RULE_REGISTRY.items(): rule_config = config.get(rule_type, {}) From c89ab040b2d78c72981e2b79caa96bfb31cd828b Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 5 Aug 2026 19:03:42 +0400 Subject: [PATCH 09/90] add evaluate_ruleset engine Signed-off-by: tdruez --- vulnerabilities/triage/engine.py | 28 ++++++++++++++++++++++++++++ vulnerabilities/triage/rules.py | 7 +++++-- 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 vulnerabilities/triage/engine.py diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py new file mode 100644 index 00000000..16935691 --- /dev/null +++ b/vulnerabilities/triage/engine.py @@ -0,0 +1,28 @@ +# +# 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 vulnerabilities.triage.rules import RULE_REGISTRY + + +def evaluate_ruleset(ruleset, product): + """ + Evaluate a TriageRuleset against a product. + + Iterates all active rules in the ruleset. If any rule detects at least one + violation, returns the ruleset's action. Returns None if no rule fires. + """ + for rule_type, config in ruleset.rules_config.items(): + if not config.get("is_active"): + continue + + handler = RULE_REGISTRY.get(rule_type) + if not handler: + continue + + if handler.count_violations(product=product) > 0: + return ruleset.action diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index 15c9ffbf..a17f7f15 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -14,13 +14,16 @@ class BaseTriageRule(BaseRule): """Base class for vulnerability triage rule handlers.""" + def count_violations(self, product): + raise NotImplementedError + class CriticalVulnerabilityTriageRule(BaseTriageRule): rule_type = "critical_vulnerability" label = "Critical Vulnerability" description = "Packages with at least one critical-severity vulnerability (risk score >= 8.0)." - def count_violations(self, product, threshold, parameters): + def count_violations(self, product): ProductPackage = apps.get_model("product_portfolio", "productpackage") return ( ProductPackage.objects.filter( @@ -37,7 +40,7 @@ class ExploitedVulnerabilityTriageRule(BaseTriageRule): label = "Exploited Vulnerability" description = "Packages with vulnerabilities for which known exploits are available." - def count_violations(self, product, threshold, parameters): + def count_violations(self, product): ProductPackage = apps.get_model("product_portfolio", "productpackage") # exploitability == 2.0 means known exploits are available return ( From 49b920769530691c6ccd4226ddaa84034aacd569 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 10:38:51 +0400 Subject: [PATCH 10/90] add TriageDecision model Signed-off-by: tdruez --- .../triage/migrations/0003_triagedecision.py | 37 +++++++++++++++++ vulnerabilities/triage/models.py | 41 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 vulnerabilities/triage/migrations/0003_triagedecision.py diff --git a/vulnerabilities/triage/migrations/0003_triagedecision.py b/vulnerabilities/triage/migrations/0003_triagedecision.py new file mode 100644 index 00000000..72c1bce9 --- /dev/null +++ b/vulnerabilities/triage/migrations/0003_triagedecision.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.6 on 2026-08-06 06:38 + +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', '0019_productaffectedbyvulnerability_detected_date'), + ('vulnerabilities_triage', '0002_triageruleset_action_alter_triageruleset_name_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='TriageDecision', + 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')), + ('action', models.CharField(choices=[('upgrade', 'Upgrade Package'), ('apply_patch', 'Apply Patch'), ('forensic_analysis', 'Forensic Analysis'), ('reachability_analysis', 'Reachability Analysis'), ('change_config', 'Change Configuration'), ('replace_package', 'Replace Package'), ('notify', 'Notify'), ('create_request', 'Create DejaCode Request')], help_text='Recommended action at the time of evaluation.', max_length=50)), + ('matched_rules', models.JSONField(default=list, help_text='List of rule types that detected violations during this evaluation.')), + ('detected_date', models.DateTimeField(auto_now_add=True, help_text='Date and time when this action was first recommended.')), + ('last_checked', models.DateTimeField(auto_now=True, help_text='Date and time of the last evaluation.')), + ('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 against which this ruleset was evaluated.', on_delete=django.db.models.deletion.CASCADE, related_name='triage_decisions', to='product_portfolio.product')), + ('ruleset', models.ForeignKey(help_text='The ruleset that produced this action.', on_delete=django.db.models.deletion.CASCADE, related_name='triage_decisions', to='vulnerabilities_triage.triageruleset')), + ], + options={ + 'ordering': ['-detected_date'], + 'unique_together': {('dataspace', 'uuid'), ('product', 'ruleset')}, + }, + bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), + ), + ] diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index 614795f1..51066520 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -60,3 +60,44 @@ class Meta: def __str__(self): return self.name + + +class TriageDecision(DataspacedModel): + """Stores the result of evaluating a TriageRuleset against a product.""" + + product = models.ForeignKey( + to="product_portfolio.Product", + on_delete=models.CASCADE, + related_name="triage_decisions", + help_text=_("The product against which this ruleset was evaluated."), + ) + ruleset = models.ForeignKey( + to="vulnerabilities_triage.TriageRuleset", + on_delete=models.CASCADE, + related_name="triage_decisions", + help_text=_("The ruleset that produced this action."), + ) + action = models.CharField( + max_length=50, + choices=TriageRuleset.Action.choices, + help_text=_("Recommended action at the time of evaluation."), + ) + matched_rules = models.JSONField( + default=list, + help_text=_("List of rule types that detected violations during this evaluation."), + ) + detected_date = models.DateTimeField( + auto_now_add=True, + help_text=_("Date and time when this action was first recommended."), + ) + last_checked = models.DateTimeField( + auto_now=True, + help_text=_("Date and time of the last evaluation."), + ) + + class Meta: + unique_together = (("dataspace", "uuid"), ("product", "ruleset")) + ordering = ["-detected_date"] + + def __str__(self): + return f"{self.ruleset} / {self.product}: {self.action}" From 34d0076e6f92b1c7c56ed136f6c90c3e6dffc615 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 10:54:39 +0400 Subject: [PATCH 11/90] refine list display Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 581bd6cc..59ef01bf 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -33,7 +33,7 @@ class TriageRulesetAdmin(DataspacedAdmin): ) form = TriageRulesetForm - list_display = ["name", "get_enabled_rules", "precedence", "enabled", "get_dataspace"] + list_display = ["name", "action", "get_enabled_rules", "enabled", "get_dataspace"] list_filter = DataspacedAdmin.list_filter + ("enabled",) search_fields = ["name"] From 345118a78b9bff4f972a6a78afb874ac0c07a940 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 10:54:58 +0400 Subject: [PATCH 12/90] add management command to load ruleset data Signed-off-by: tdruez --- .../commands/create_triage_rulesets.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 vulnerabilities/triage/management/commands/create_triage_rulesets.py diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py new file mode 100644 index 00000000..5c314b74 --- /dev/null +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -0,0 +1,90 @@ +# +# 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.core.management.base import BaseCommand +from django.core.management.base import CommandError + +from dje.models import Dataspace +from vulnerabilities.triage.models import TriageRuleset + +""" +docker compose -f compose.dev.yml exec web ./manage.py create_triage_rulesets nexB +""" + +REFERENCE_RULESETS = [ + { + "name": "Active Exploit", + "description": "Packages with known active exploits require immediate upgrade.", + "action": TriageRuleset.Action.UPGRADE, + "precedence": 300, + "rules_config": { + "exploited_vulnerability": {"is_active": True}, + }, + }, + { + "name": "Critical Vulnerability", + "description": ( + "Critical vulnerabilities with no known exploit require forensic analysis." + ), + "action": TriageRuleset.Action.FORENSIC_ANALYSIS, + "precedence": 200, + "rules_config": { + "critical_vulnerability": {"is_active": True}, + }, + }, + { + "name": "Critical + Exploited", + "description": ("Both critical severity and active exploit detected, upgrade immediately."), + "action": TriageRuleset.Action.UPGRADE, + "precedence": 400, + "rules_config": { + "critical_vulnerability": {"is_active": True}, + "exploited_vulnerability": {"is_active": True}, + }, + }, +] + + +class Command(BaseCommand): + help = "Create reference triage rulesets in the given dataspace." + + def add_arguments(self, parser): + parser.add_argument("dataspace", help="Name of the target Dataspace.") + + def handle(self, *args, **options): + dataspace_name = options["dataspace"] + + try: + dataspace = Dataspace.objects.get(name=dataspace_name) + except Dataspace.DoesNotExist: + raise CommandError(f'Dataspace "{dataspace_name}" does not exist.') + + created_count = 0 + for ruleset_data in REFERENCE_RULESETS: + _, created = TriageRuleset.objects.get_or_create( + dataspace=dataspace, + name=ruleset_data["name"], + defaults={ + "description": ruleset_data["description"], + "action": ruleset_data["action"], + "precedence": ruleset_data["precedence"], + "rules_config": ruleset_data["rules_config"], + "enabled": True, + }, + ) + if created: + created_count += 1 + self.stdout.write(f" Created: {ruleset_data['name']}") + else: + self.stdout.write(f" Already exists: {ruleset_data['name']}") + + self.stdout.write( + self.style.SUCCESS( + f"{created_count} ruleset(s) created in dataspace '{dataspace_name}'." + ) + ) From 691b3694b8d7fa46d43de0973952f3c6327d0463 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 11:38:15 +0400 Subject: [PATCH 13/90] add command to trigger triage evaluation Signed-off-by: tdruez --- vulnerabilities/triage/engine.py | 11 ++- vulnerabilities/triage/management/__init__.py | 0 .../triage/management/commands/__init__.py | 0 .../management/commands/evaluate_triage.py | 87 +++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 vulnerabilities/triage/management/__init__.py create mode 100644 vulnerabilities/triage/management/commands/__init__.py create mode 100644 vulnerabilities/triage/management/commands/evaluate_triage.py diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index 16935691..6e8f07c9 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -13,9 +13,11 @@ def evaluate_ruleset(ruleset, product): """ Evaluate a TriageRuleset against a product. - Iterates all active rules in the ruleset. If any rule detects at least one - violation, returns the ruleset's action. Returns None if no rule fires. + Iterates all active rules in the ruleset. Returns a dict with the recommended + action and the list of rule types that fired, or None if no rule fires. """ + matched_rules = [] + for rule_type, config in ruleset.rules_config.items(): if not config.get("is_active"): continue @@ -25,4 +27,7 @@ def evaluate_ruleset(ruleset, product): continue if handler.count_violations(product=product) > 0: - return ruleset.action + matched_rules.append(rule_type) + + if matched_rules: + return {"action": ruleset.action, "matched_rules": matched_rules} diff --git a/vulnerabilities/triage/management/__init__.py b/vulnerabilities/triage/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vulnerabilities/triage/management/commands/__init__.py b/vulnerabilities/triage/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vulnerabilities/triage/management/commands/evaluate_triage.py b/vulnerabilities/triage/management/commands/evaluate_triage.py new file mode 100644 index 00000000..39a1069f --- /dev/null +++ b/vulnerabilities/triage/management/commands/evaluate_triage.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. +# + +from django.apps import apps +from django.core.management.base import BaseCommand +from django.core.management.base import CommandError + +from dje.models import Dataspace +from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.models import TriageDecision +from vulnerabilities.triage.models import TriageRuleset + +""" +docker compose -f compose.dev.yml exec web ./manage.py evaluate_triage nexB +""" + +class Command(BaseCommand): + help = "Evaluate all enabled triage rulesets against all products in the given dataspace." + + def add_arguments(self, parser): + parser.add_argument("dataspace", help="Name of the target Dataspace.") + + def handle(self, *args, **options): + dataspace_name = options["dataspace"] + + try: + dataspace = Dataspace.objects.get(name=dataspace_name) + except Dataspace.DoesNotExist: + raise CommandError(f'Dataspace "{dataspace_name}" does not exist.') + + Product = apps.get_model("product_portfolio", "product") + products = Product.unsecured_objects.scope(dataspace) + rulesets = TriageRuleset.objects.filter(dataspace=dataspace, enabled=True) + + product_count = products.count() + ruleset_count = rulesets.count() + + self.stdout.write(f"Products: {product_count}, rulesets: {ruleset_count}") + + if not ruleset_count: + self.stdout.write("No enabled rulesets found.") + return + + if not product_count: + self.stdout.write("No products found.") + return + + created_count = 0 + updated_count = 0 + no_match_count = 0 + + for product in products: + for ruleset in rulesets: + result = evaluate_ruleset(ruleset=ruleset, product=product) + if not result: + no_match_count += 1 + continue + + _, created = TriageDecision.objects.update_or_create( + dataspace=dataspace, + product=product, + ruleset=ruleset, + defaults={ + "action": result["action"], + "matched_rules": result["matched_rules"], + }, + ) + + label = f"{product} -> {ruleset}" + if created: + created_count += 1 + self.stdout.write(f" [new] {label}: {result['action']}") + else: + updated_count += 1 + self.stdout.write(f" [updated] {label}: {result['action']}") + + self.stdout.write( + self.style.SUCCESS( + f"Done: {created_count} created, {updated_count} updated," + f" {no_match_count} no match." + ) + ) From 24653710970ae4b214b0b7199754a3f87839c6f6 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 11:42:05 +0400 Subject: [PATCH 14/90] implement TriageDecisionAdmin Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 55 +++++++++++++++++++ .../management/commands/evaluate_triage.py | 1 + vulnerabilities/triage/models.py | 5 ++ 3 files changed, 61 insertions(+) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 59ef01bf..e59f2032 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -13,6 +13,7 @@ from dje.admin import DataspacedAdmin from dje.admin import dejacode_site from vulnerabilities.triage.forms import TriageRulesetForm +from vulnerabilities.triage.models import TriageDecision from vulnerabilities.triage.models import TriageRuleset from vulnerabilities.triage.rules import RULE_REGISTRY @@ -76,3 +77,57 @@ def get_fieldsets(self, request, obj=None): for rule_type, handler in RULE_REGISTRY.items() ] return base_fieldsets + rule_fieldsets + + +@admin.register(TriageDecision, site=dejacode_site) +class TriageDecisionAdmin(DataspacedAdmin): + short_description = ( + "A Triage Decision records the result of evaluating a ruleset against a product." + ) + long_description = ( + "Triage Decisions are created automatically by the evaluation engine. Each record" + " stores the recommended action, the rules that matched, and the dates of first" + " detection and last check. These records are read-only in the admin." + ) + + list_display = [ + "product", + "ruleset", + "action", + "get_matched_rules", + "detected_date", + "last_checked", + "get_dataspace", + ] + list_filter = DataspacedAdmin.list_filter + ("action", "ruleset") + search_fields = ["product__name", "ruleset__name"] + readonly_fields = DataspacedAdmin.readonly_fields + ( + "product", + "ruleset", + "action", + "matched_rules", + "detected_date", + "last_checked", + ) + + @admin.display(description="Matched rules") + def get_matched_rules(self, obj): + labels = [ + RULE_REGISTRY[rule_type].label + for rule_type in obj.matched_rules + if rule_type in RULE_REGISTRY + ] + if not labels: + return "" + return mark_safe("
".join(escape(label) for label in labels)) + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .product_secured(request.user, "view_product") + .select_related("product", "ruleset") + ) + + def has_add_permission(self, request): + return False diff --git a/vulnerabilities/triage/management/commands/evaluate_triage.py b/vulnerabilities/triage/management/commands/evaluate_triage.py index 39a1069f..4de763a1 100644 --- a/vulnerabilities/triage/management/commands/evaluate_triage.py +++ b/vulnerabilities/triage/management/commands/evaluate_triage.py @@ -19,6 +19,7 @@ docker compose -f compose.dev.yml exec web ./manage.py evaluate_triage nexB """ + class Command(BaseCommand): help = "Evaluate all enabled triage rulesets against all products in the given dataspace." diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index 51066520..42045d50 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -9,7 +9,9 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from dje.models import DataspacedManager from dje.models import DataspacedModel +from dje.models import ProductSecuredQuerySet class TriageRuleset(DataspacedModel): @@ -86,6 +88,9 @@ class TriageDecision(DataspacedModel): default=list, help_text=_("List of rule types that detected violations during this evaluation."), ) + + objects = DataspacedManager.from_queryset(ProductSecuredQuerySet)() + detected_date = models.DateTimeField( auto_now_add=True, help_text=_("Date and time when this action was first recommended."), From 41121a32abc79ff51300f8efd8d6c53c6f87070f Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 12:59:36 +0400 Subject: [PATCH 15/90] add new triage rules Signed-off-by: tdruez --- vulnerabilities/triage/rules.py | 204 ++++++++++++++++++++++++++++++-- 1 file changed, 195 insertions(+), 9 deletions(-) diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index a17f7f15..ab6322f7 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -6,29 +6,84 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +from datetime import timedelta + from django.apps import apps +from django.db.models import Exists +from django.db.models import OuterRef +from django.utils import timezone from policy.rules import BaseRule +TERMINAL_VULNERABILITY_STATES = [ + "resolved", + "resolved_with_pedigree", + "not_affected", + "false_positive", +] + class BaseTriageRule(BaseRule): """Base class for vulnerability triage rule handlers.""" - def count_violations(self, product): + parameters_schema = {} + + def count_violations(self, product, parameters=None): raise NotImplementedError -class CriticalVulnerabilityTriageRule(BaseTriageRule): - rule_type = "critical_vulnerability" - label = "Critical Vulnerability" - description = "Packages with at least one critical-severity vulnerability (risk score >= 8.0)." +class RiskScoreTriageRule(BaseTriageRule): + rule_type = "risk_score" + label = "Risk Score" + description = ( + "Packages with at least one vulnerability at or above the configured risk score threshold." + ) + parameters_schema = { + "min_risk_score": { + "default": 8.0, + "help_text": "Minimum vulnerability risk score (0.0-10.0). Default: 8.0.", + }, + } + + def count_violations(self, product, parameters=None): + ProductPackage = apps.get_model("product_portfolio", "productpackage") + min_risk_score = (parameters or {}).get( + "min_risk_score", self.parameters_schema["min_risk_score"]["default"] + ) + return ( + ProductPackage.objects.filter( + product=product, + package__affected_by_vulnerabilities__risk_score__gte=min_risk_score, + ) + .distinct() + .count() + ) + + +class WeightedRiskTriageRule(BaseTriageRule): + rule_type = "weighted_risk" + label = "Weighted Risk" + description = ( + "Packages whose weighted risk score (risk adjusted by purpose exposure factor)" + " is at or above the configured threshold." + ) + parameters_schema = { + "min_weighted_risk_score": { + "default": 8.0, + "help_text": "Minimum weighted risk score (0.0-10.0). Default: 8.0.", + }, + } - def count_violations(self, product): + def count_violations(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") + min_weighted_risk_score = (parameters or {}).get( + "min_weighted_risk_score", + self.parameters_schema["min_weighted_risk_score"]["default"], + ) return ( ProductPackage.objects.filter( product=product, - package__affected_by_vulnerabilities__risk_level="critical", + weighted_risk_score__gte=min_weighted_risk_score, ) .distinct() .count() @@ -40,7 +95,7 @@ class ExploitedVulnerabilityTriageRule(BaseTriageRule): label = "Exploited Vulnerability" description = "Packages with vulnerabilities for which known exploits are available." - def count_violations(self, product): + def count_violations(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") # exploitability == 2.0 means known exploits are available return ( @@ -53,7 +108,138 @@ def count_violations(self, product): ) +class ReachableVulnerabilityTriageRule(BaseTriageRule): + rule_type = "reachable_vulnerability" + label = "Reachable Vulnerability" + description = ( + "Packages with at least one vulnerability confirmed as reachable in the product context." + ) + + def count_violations(self, product, parameters=None): + ProductPackage = apps.get_model("product_portfolio", "productpackage") + VulnerabilityAnalysis = apps.get_model("vulnerabilities", "vulnerabilityanalysis") + reachable_analysis = VulnerabilityAnalysis.objects.filter( + product_package=OuterRef("pk"), + is_reachable=True, + ) + return ( + ProductPackage.objects.filter(product=product) + .filter(Exists(reachable_analysis)) + .distinct() + .count() + ) + + +class UnresolvedVulnerabilityTriageRule(BaseTriageRule): + rule_type = "unresolved_vulnerability" + label = "Unresolved Vulnerability" + description = "Packages with known vulnerabilities that have no completed triage analysis." + + def count_violations(self, product, parameters=None): + ProductPackage = apps.get_model("product_portfolio", "productpackage") + PackageAffectedByVulnerability = apps.get_model( + "component_catalog", "packageaffectedbyvulnerability" + ) + VulnerabilityAnalysis = apps.get_model("vulnerabilities", "vulnerabilityanalysis") + terminal_analysis = VulnerabilityAnalysis.objects.filter( + product_package=OuterRef(OuterRef("pk")), + state__in=TERMINAL_VULNERABILITY_STATES, + vulnerability=OuterRef("vulnerability"), + ) + unresolved_link = ( + PackageAffectedByVulnerability.objects.filter(package=OuterRef("package")) + .annotate(has_terminal=Exists(terminal_analysis)) + .filter(has_terminal=False) + ) + return ( + ProductPackage.objects.filter(product=product) + .filter(Exists(unresolved_link)) + .distinct() + .count() + ) + + +class StaleVulnerabilityTriageRule(BaseTriageRule): + rule_type = "stale_vulnerability" + label = "Stale Vulnerability" + description = ( + "Packages with vulnerabilities above the configured risk score threshold unaddressed" + " beyond the configured number of days." + ) + parameters_schema = { + "min_risk_score": { + "default": 8.0, + "help_text": "Minimum vulnerability risk score to consider (0.0-10.0). Default: 8.0.", + }, + "max_days": { + "default": 30, + "help_text": ( + "Maximum number of days a vulnerability may remain unaddressed. Default: 30." + ), + }, + } + + def count_violations(self, product, parameters=None): + ProductPackage = apps.get_model("product_portfolio", "productpackage") + PackageAffectedByVulnerability = apps.get_model( + "component_catalog", "packageaffectedbyvulnerability" + ) + VulnerabilityAnalysis = apps.get_model("vulnerabilities", "vulnerabilityanalysis") + parameters = parameters or {} + min_risk_score = parameters.get( + "min_risk_score", self.parameters_schema["min_risk_score"]["default"] + ) + max_days = parameters.get("max_days", self.parameters_schema["max_days"]["default"]) + cutoff_date = timezone.now() - timedelta(days=max_days) + terminal_analysis = VulnerabilityAnalysis.objects.filter( + product_package=OuterRef(OuterRef("pk")), + state__in=TERMINAL_VULNERABILITY_STATES, + vulnerability=OuterRef("vulnerability"), + ) + stale_link = ( + PackageAffectedByVulnerability.objects.filter( + package=OuterRef("package"), + vulnerability__risk_score__gte=min_risk_score, + detected_date__lte=cutoff_date, + ) + .annotate(has_terminal=Exists(terminal_analysis)) + .filter(has_terminal=False) + ) + return ( + ProductPackage.objects.filter(product=product) + .filter(Exists(stale_link)) + .distinct() + .count() + ) + + +class DevOnlyPackageTriageRule(BaseTriageRule): + rule_type = "dev_only_vulnerable_package" + label = "Dev-Only Vulnerable Package" + description = ( + "Packages not deployed in production (is_deployed=False) that are affected" + " by vulnerabilities." + ) + + def count_violations(self, product, parameters=None): + ProductPackage = apps.get_model("product_portfolio", "productpackage") + return ( + ProductPackage.objects.filter( + product=product, + is_deployed=False, + package__affected_by_vulnerabilities__isnull=False, + ) + .distinct() + .count() + ) + + RULE_REGISTRY = { - CriticalVulnerabilityTriageRule.rule_type: CriticalVulnerabilityTriageRule(), + RiskScoreTriageRule.rule_type: RiskScoreTriageRule(), + WeightedRiskTriageRule.rule_type: WeightedRiskTriageRule(), ExploitedVulnerabilityTriageRule.rule_type: ExploitedVulnerabilityTriageRule(), + ReachableVulnerabilityTriageRule.rule_type: ReachableVulnerabilityTriageRule(), + UnresolvedVulnerabilityTriageRule.rule_type: UnresolvedVulnerabilityTriageRule(), + StaleVulnerabilityTriageRule.rule_type: StaleVulnerabilityTriageRule(), + DevOnlyPackageTriageRule.rule_type: DevOnlyPackageTriageRule(), } From bf9d578d9386f920cc4b6fd2915bb95c00bf18f6 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 13:01:41 +0400 Subject: [PATCH 16/90] rename function Signed-off-by: tdruez --- vulnerabilities/triage/rules.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index ab6322f7..bbdc8bd1 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -28,7 +28,7 @@ class BaseTriageRule(BaseRule): parameters_schema = {} - def count_violations(self, product, parameters=None): + def count_matches(self, product, parameters=None): raise NotImplementedError @@ -45,7 +45,7 @@ class RiskScoreTriageRule(BaseTriageRule): }, } - def count_violations(self, product, parameters=None): + def count_matches(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") min_risk_score = (parameters or {}).get( "min_risk_score", self.parameters_schema["min_risk_score"]["default"] @@ -74,7 +74,7 @@ class WeightedRiskTriageRule(BaseTriageRule): }, } - def count_violations(self, product, parameters=None): + def count_matches(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") min_weighted_risk_score = (parameters or {}).get( "min_weighted_risk_score", @@ -95,7 +95,7 @@ class ExploitedVulnerabilityTriageRule(BaseTriageRule): label = "Exploited Vulnerability" description = "Packages with vulnerabilities for which known exploits are available." - def count_violations(self, product, parameters=None): + def count_matches(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") # exploitability == 2.0 means known exploits are available return ( @@ -115,7 +115,7 @@ class ReachableVulnerabilityTriageRule(BaseTriageRule): "Packages with at least one vulnerability confirmed as reachable in the product context." ) - def count_violations(self, product, parameters=None): + def count_matches(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") VulnerabilityAnalysis = apps.get_model("vulnerabilities", "vulnerabilityanalysis") reachable_analysis = VulnerabilityAnalysis.objects.filter( @@ -135,7 +135,7 @@ class UnresolvedVulnerabilityTriageRule(BaseTriageRule): label = "Unresolved Vulnerability" description = "Packages with known vulnerabilities that have no completed triage analysis." - def count_violations(self, product, parameters=None): + def count_matches(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") PackageAffectedByVulnerability = apps.get_model( "component_catalog", "packageaffectedbyvulnerability" @@ -179,7 +179,7 @@ class StaleVulnerabilityTriageRule(BaseTriageRule): }, } - def count_violations(self, product, parameters=None): + def count_matches(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") PackageAffectedByVulnerability = apps.get_model( "component_catalog", "packageaffectedbyvulnerability" @@ -221,7 +221,7 @@ class DevOnlyPackageTriageRule(BaseTriageRule): " by vulnerabilities." ) - def count_violations(self, product, parameters=None): + def count_matches(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") return ( ProductPackage.objects.filter( From 333bc7256625d7410532ac75cf3b77c5855a7d01 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 13:06:24 +0400 Subject: [PATCH 17/90] refine admin rendering Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 47 ++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index e59f2032..18a646eb 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -34,20 +34,27 @@ class TriageRulesetAdmin(DataspacedAdmin): ) form = TriageRulesetForm - list_display = ["name", "action", "get_enabled_rules", "enabled", "get_dataspace"] + list_display = ["name", "action", "precedence", "get_enabled_rules", "enabled", "get_dataspace"] list_filter = DataspacedAdmin.list_filter + ("enabled",) search_fields = ["name"] @admin.display(description="Enabled rules") def get_enabled_rules(self, obj): - enabled = [ - RULE_REGISTRY[rule_type].label - for rule_type, config in obj.rules_config.items() - if rule_type in RULE_REGISTRY and config.get("is_active") - ] - if not enabled: + lines = [] + for rule_type, config in obj.rules_config.items(): + if rule_type not in RULE_REGISTRY or not config.get("is_active"): + continue + handler = RULE_REGISTRY[rule_type] + params = {key: value for key, value in config.items() if key != "is_active"} + if params: + param_str = ", ".join(f"{key}: {value}" for key, value in params.items()) + label = f"{handler.label} ({param_str})" + else: + label = handler.label + lines.append(escape(label)) + if not lines: return "" - return mark_safe("
".join(escape(label) for label in enabled)) + return mark_safe("
".join(lines)) def get_changes_details(self, form): model_field_names = {field.name for field in TriageRuleset._meta.get_fields()} @@ -65,17 +72,21 @@ def get_fieldsets(self, request, obj=None): {"fields": ["name", "description", "action", "precedence", "enabled"]}, ), ] - rule_fieldsets = [ - ( - handler.label, - { - "fields": [f"rule_{rule_type}_enabled"], - "description": handler.description, - "classes": ("grp-collapse grp-open",), - }, + rule_fieldsets = [] + for rule_type, handler in RULE_REGISTRY.items(): + fields = [f"rule_{rule_type}_enabled"] + for param_name in handler.parameters_schema: + fields.append(f"rule_{rule_type}_{param_name}") + rule_fieldsets.append( + ( + handler.label, + { + "fields": fields, + "description": handler.description, + "classes": ("grp-collapse grp-open",), + }, + ) ) - for rule_type, handler in RULE_REGISTRY.items() - ] return base_fieldsets + rule_fieldsets From ddd3c51f94ded7069641b64673aeba535117f88a Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 13:06:45 +0400 Subject: [PATCH 18/90] add parameter in evaluation Signed-off-by: tdruez --- vulnerabilities/triage/engine.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index 6e8f07c9..dcc05c3f 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -26,7 +26,8 @@ def evaluate_ruleset(ruleset, product): if not handler: continue - if handler.count_violations(product=product) > 0: + parameters = {key: value for key, value in config.items() if key != "is_active"} + if handler.count_matches(product=product, parameters=parameters) > 0: matched_rules.append(rule_type) if matched_rules: From a4c788d263e891a9f07e09f976fa9681c986e255 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 13:14:21 +0400 Subject: [PATCH 19/90] updat the reference ruleset Signed-off-by: tdruez --- .../commands/create_triage_rulesets.py | 75 ++++++++++++++++--- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index 5c314b74..5eee80ad 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -17,34 +17,89 @@ """ REFERENCE_RULESETS = [ + { + "name": "Critical Exploited Vulnerability", + "description": ( + "Critical-severity vulnerability (risk score >= 8.0) with a known active exploit." + " Requires immediate package upgrade." + ), + "action": TriageRuleset.Action.UPGRADE, + "precedence": 400, + "rules_config": { + "risk_score": {"is_active": True, "min_risk_score": 8.0}, + "exploited_vulnerability": {"is_active": True}, + }, + }, { "name": "Active Exploit", - "description": "Packages with known active exploits require immediate upgrade.", + "description": ( + "Vulnerability with a known active exploit, regardless of severity." + " Requires immediate package upgrade." + ), "action": TriageRuleset.Action.UPGRADE, "precedence": 300, "rules_config": { "exploited_vulnerability": {"is_active": True}, }, }, + { + "name": "Reachable Vulnerability", + "description": ( + "Vulnerability confirmed as reachable in the product context." + " Requires applying a patch." + ), + "action": TriageRuleset.Action.APPLY_PATCH, + "precedence": 250, + "rules_config": { + "reachable_vulnerability": {"is_active": True}, + }, + }, { "name": "Critical Vulnerability", "description": ( - "Critical vulnerabilities with no known exploit require forensic analysis." + "Critical-severity vulnerability (risk score >= 8.0) with no known exploit." + " Requires applying a patch." ), - "action": TriageRuleset.Action.FORENSIC_ANALYSIS, + "action": TriageRuleset.Action.APPLY_PATCH, "precedence": 200, "rules_config": { - "critical_vulnerability": {"is_active": True}, + "risk_score": {"is_active": True, "min_risk_score": 8.0}, }, }, { - "name": "Critical + Exploited", - "description": ("Both critical severity and active exploit detected, upgrade immediately."), - "action": TriageRuleset.Action.UPGRADE, - "precedence": 400, + "name": "Stale Vulnerability", + "description": ( + "Critical-severity vulnerability (risk score >= 8.0) unaddressed for more than 30 days." + " Requires applying a patch without further delay." + ), + "action": TriageRuleset.Action.APPLY_PATCH, + "precedence": 150, "rules_config": { - "critical_vulnerability": {"is_active": True}, - "exploited_vulnerability": {"is_active": True}, + "stale_vulnerability": {"is_active": True, "min_risk_score": 8.0, "max_days": 30}, + }, + }, + { + "name": "Unresolved Vulnerability", + "description": ( + "Packages with known vulnerabilities that have no completed triage analysis." + " Requires forensic analysis to determine impact and next steps." + ), + "action": TriageRuleset.Action.FORENSIC_ANALYSIS, + "precedence": 100, + "rules_config": { + "unresolved_vulnerability": {"is_active": True}, + }, + }, + { + "name": "Dev-Only Vulnerable Package", + "description": ( + "Packages not deployed in production that are affected by vulnerabilities." + " Lower urgency, notify the team for awareness." + ), + "action": TriageRuleset.Action.NOTIFY, + "precedence": 50, + "rules_config": { + "dev_only_vulnerable_package": {"is_active": True}, }, }, ] From 795c533eb6cbb19047c60b75b303993c436b4740 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 13:19:51 +0400 Subject: [PATCH 20/90] display description in admin Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 18a646eb..eb9fc8c1 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -34,7 +34,15 @@ class TriageRulesetAdmin(DataspacedAdmin): ) form = TriageRulesetForm - list_display = ["name", "action", "precedence", "get_enabled_rules", "enabled", "get_dataspace"] + list_display = [ + "name", + "action", + "precedence", + "get_enabled_rules", + "enabled", + "description", + "get_dataspace", + ] list_filter = DataspacedAdmin.list_filter + ("enabled",) search_fields = ["name"] From 9833db113ab67ad060af69743e80cf40ba7454ad Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 13:21:47 +0400 Subject: [PATCH 21/90] refine default rules Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 2 +- .../commands/create_triage_rulesets.py | 32 +++++-------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index eb9fc8c1..71489ce0 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -39,8 +39,8 @@ class TriageRulesetAdmin(DataspacedAdmin): "action", "precedence", "get_enabled_rules", - "enabled", "description", + "enabled", "get_dataspace", ] list_filter = DataspacedAdmin.list_filter + ("enabled",) diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index 5eee80ad..3d09db4e 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -19,10 +19,7 @@ REFERENCE_RULESETS = [ { "name": "Critical Exploited Vulnerability", - "description": ( - "Critical-severity vulnerability (risk score >= 8.0) with a known active exploit." - " Requires immediate package upgrade." - ), + "description": "Packages with a critical vulnerability and a known active exploit.", "action": TriageRuleset.Action.UPGRADE, "precedence": 400, "rules_config": { @@ -32,10 +29,7 @@ }, { "name": "Active Exploit", - "description": ( - "Vulnerability with a known active exploit, regardless of severity." - " Requires immediate package upgrade." - ), + "description": "Packages with a known exploit available, regardless of severity.", "action": TriageRuleset.Action.UPGRADE, "precedence": 300, "rules_config": { @@ -45,8 +39,7 @@ { "name": "Reachable Vulnerability", "description": ( - "Vulnerability confirmed as reachable in the product context." - " Requires applying a patch." + "Packages with a vulnerability confirmed as reachable in the product context." ), "action": TriageRuleset.Action.APPLY_PATCH, "precedence": 250, @@ -56,10 +49,7 @@ }, { "name": "Critical Vulnerability", - "description": ( - "Critical-severity vulnerability (risk score >= 8.0) with no known exploit." - " Requires applying a patch." - ), + "description": "Packages with a critical-severity vulnerability and no known exploit.", "action": TriageRuleset.Action.APPLY_PATCH, "precedence": 200, "rules_config": { @@ -69,8 +59,8 @@ { "name": "Stale Vulnerability", "description": ( - "Critical-severity vulnerability (risk score >= 8.0) unaddressed for more than 30 days." - " Requires applying a patch without further delay." + "Packages with a critical-severity vulnerability left unaddressed" + " for more than 30 days." ), "action": TriageRuleset.Action.APPLY_PATCH, "precedence": 150, @@ -80,10 +70,7 @@ }, { "name": "Unresolved Vulnerability", - "description": ( - "Packages with known vulnerabilities that have no completed triage analysis." - " Requires forensic analysis to determine impact and next steps." - ), + "description": "Packages with vulnerabilities that have no completed triage analysis.", "action": TriageRuleset.Action.FORENSIC_ANALYSIS, "precedence": 100, "rules_config": { @@ -92,10 +79,7 @@ }, { "name": "Dev-Only Vulnerable Package", - "description": ( - "Packages not deployed in production that are affected by vulnerabilities." - " Lower urgency, notify the team for awareness." - ), + "description": "Packages not deployed in production that are affected by vulnerabilities.", "action": TriageRuleset.Action.NOTIFY, "precedence": 50, "rules_config": { From 0d64a0b92c01fa99ae3b58ef1a4e23393c313d45 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 13:28:05 +0400 Subject: [PATCH 22/90] refine rule descriptions Signed-off-by: tdruez --- vulnerabilities/triage/rules.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index bbdc8bd1..1e398f0c 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -35,9 +35,7 @@ def count_matches(self, product, parameters=None): class RiskScoreTriageRule(BaseTriageRule): rule_type = "risk_score" label = "Risk Score" - description = ( - "Packages with at least one vulnerability at or above the configured risk score threshold." - ) + description = "Packages with a vulnerability at or above the configured risk score." parameters_schema = { "min_risk_score": { "default": 8.0, @@ -63,10 +61,7 @@ def count_matches(self, product, parameters=None): class WeightedRiskTriageRule(BaseTriageRule): rule_type = "weighted_risk" label = "Weighted Risk" - description = ( - "Packages whose weighted risk score (risk adjusted by purpose exposure factor)" - " is at or above the configured threshold." - ) + description = "Packages with a weighted risk score at or above the configured threshold." parameters_schema = { "min_weighted_risk_score": { "default": 8.0, @@ -93,7 +88,7 @@ def count_matches(self, product, parameters=None): class ExploitedVulnerabilityTriageRule(BaseTriageRule): rule_type = "exploited_vulnerability" label = "Exploited Vulnerability" - description = "Packages with vulnerabilities for which known exploits are available." + description = "Packages with a vulnerability for which a known exploit is available." def count_matches(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") @@ -111,9 +106,7 @@ def count_matches(self, product, parameters=None): class ReachableVulnerabilityTriageRule(BaseTriageRule): rule_type = "reachable_vulnerability" label = "Reachable Vulnerability" - description = ( - "Packages with at least one vulnerability confirmed as reachable in the product context." - ) + description = "Packages with a vulnerability confirmed as reachable in the product context." def count_matches(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") @@ -133,7 +126,7 @@ def count_matches(self, product, parameters=None): class UnresolvedVulnerabilityTriageRule(BaseTriageRule): rule_type = "unresolved_vulnerability" label = "Unresolved Vulnerability" - description = "Packages with known vulnerabilities that have no completed triage analysis." + description = "Packages with known vulnerabilities that have no completed analysis." def count_matches(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") @@ -163,13 +156,13 @@ class StaleVulnerabilityTriageRule(BaseTriageRule): rule_type = "stale_vulnerability" label = "Stale Vulnerability" description = ( - "Packages with vulnerabilities above the configured risk score threshold unaddressed" - " beyond the configured number of days." + "Packages with vulnerabilities above the configured risk score," + " unaddressed beyond the configured number of days." ) parameters_schema = { "min_risk_score": { "default": 8.0, - "help_text": "Minimum vulnerability risk score to consider (0.0-10.0). Default: 8.0.", + "help_text": "Minimum vulnerability risk score (0.0-10.0). Default: 8.0.", }, "max_days": { "default": 30, @@ -216,10 +209,7 @@ def count_matches(self, product, parameters=None): class DevOnlyPackageTriageRule(BaseTriageRule): rule_type = "dev_only_vulnerable_package" label = "Dev-Only Vulnerable Package" - description = ( - "Packages not deployed in production (is_deployed=False) that are affected" - " by vulnerabilities." - ) + description = "Packages not deployed in production that are affected by vulnerabilities." def count_matches(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") From 113c40c4316b5280ad7caa4f0a3c74e36c1ec783 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 13:28:53 +0400 Subject: [PATCH 23/90] add parameter in form Signed-off-by: tdruez --- vulnerabilities/triage/forms.py | 41 ++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py index ef0702b8..dd6e993a 100644 --- a/vulnerabilities/triage/forms.py +++ b/vulnerabilities/triage/forms.py @@ -22,6 +22,28 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.add_rule_fields() + def build_parameter_field(self, param_name, param_spec, initial_value): + label = param_name.replace("_", " ").capitalize() + help_text = param_spec["help_text"] + default = param_spec["default"] + if isinstance(default, int): + return forms.IntegerField( + label=label, + help_text=help_text, + required=False, + initial=initial_value, + min_value=1, + ) + return forms.DecimalField( + label=label, + help_text=help_text, + required=False, + initial=initial_value, + min_value=0, + max_value=10, + decimal_places=1, + ) + def add_rule_fields(self): config = getattr(self.instance, "rules_config", {}) or {} for rule_type, handler in RULE_REGISTRY.items(): @@ -31,12 +53,25 @@ def add_rule_fields(self): required=False, initial=rule_config.get("is_active", False), ) + for param_name, param_spec in handler.parameters_schema.items(): + default = param_spec["default"] + initial_value = rule_config.get(param_name, default) + field = self.build_parameter_field(param_name, param_spec, initial_value) + self.fields[f"rule_{rule_type}_{param_name}"] = field def build_rules_config(self): rules_config = {} - for rule_type in RULE_REGISTRY: - if self.cleaned_data.get(f"rule_{rule_type}_enabled"): - rules_config[rule_type] = {"is_active": True} + for rule_type, handler in RULE_REGISTRY.items(): + if not self.cleaned_data.get(f"rule_{rule_type}_enabled"): + continue + rule_config = {"is_active": True} + for param_name, param_spec in handler.parameters_schema.items(): + value = self.cleaned_data.get(f"rule_{rule_type}_{param_name}") + if value is not None: + default = param_spec["default"] + coerced_value = int(value) if isinstance(default, int) else float(value) + rule_config[param_name] = coerced_value + rules_config[rule_type] = rule_config return rules_config def save(self, commit=True): From 9545c166df30817eaa9000c9c1e183b9a2e5a2ea Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 15:09:18 +0400 Subject: [PATCH 24/90] fix form bugs Signed-off-by: tdruez --- vulnerabilities/triage/forms.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py index dd6e993a..e5e8b675 100644 --- a/vulnerabilities/triage/forms.py +++ b/vulnerabilities/triage/forms.py @@ -48,29 +48,37 @@ def add_rule_fields(self): config = getattr(self.instance, "rules_config", {}) or {} for rule_type, handler in RULE_REGISTRY.items(): rule_config = config.get(rule_type, {}) - self.fields[f"rule_{rule_type}_enabled"] = forms.BooleanField( + enable_key = f"rule_{rule_type}_enabled" + is_active = rule_config.get("is_active", False) + self.fields[enable_key] = forms.BooleanField( label=f"Enable {handler.label}", required=False, - initial=rule_config.get("is_active", False), + initial=is_active, ) + self.initial[enable_key] = is_active for param_name, param_spec in handler.parameters_schema.items(): default = param_spec["default"] initial_value = rule_config.get(param_name, default) field = self.build_parameter_field(param_name, param_spec, initial_value) - self.fields[f"rule_{rule_type}_{param_name}"] = field + param_key = f"rule_{rule_type}_{param_name}" + self.fields[param_key] = field + self.initial[param_key] = initial_value def build_rules_config(self): rules_config = {} for rule_type, handler in RULE_REGISTRY.items(): - if not self.cleaned_data.get(f"rule_{rule_type}_enabled"): + is_active = bool(self.cleaned_data.get(f"rule_{rule_type}_enabled")) + if not is_active and not handler.parameters_schema: continue - rule_config = {"is_active": True} + rule_config = {"is_active": is_active} for param_name, param_spec in handler.parameters_schema.items(): + default = param_spec["default"] value = self.cleaned_data.get(f"rule_{rule_type}_{param_name}") if value is not None: - default = param_spec["default"] - coerced_value = int(value) if isinstance(default, int) else float(value) - rule_config[param_name] = coerced_value + coerced = int(value) if isinstance(default, int) else float(value) + rule_config[param_name] = coerced + else: + rule_config[param_name] = default rules_config[rule_type] = rule_config return rules_config From 47eafdf64b3019825a61900254c50f67f30de14b Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 6 Aug 2026 15:58:51 +0400 Subject: [PATCH 25/90] refactor the qactions outside the model Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 15 +++++++++--- vulnerabilities/triage/forms.py | 6 +++++ .../commands/create_triage_rulesets.py | 15 ++++++------ ...04_alter_triagedecision_action_and_more.py | 23 +++++++++++++++++++ vulnerabilities/triage/models.py | 23 +++++++++---------- 5 files changed, 60 insertions(+), 22 deletions(-) create mode 100644 vulnerabilities/triage/migrations/0004_alter_triagedecision_action_and_more.py diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 71489ce0..1c11a915 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -13,6 +13,7 @@ from dje.admin import DataspacedAdmin from dje.admin import dejacode_site from vulnerabilities.triage.forms import TriageRulesetForm +from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageDecision from vulnerabilities.triage.models import TriageRuleset from vulnerabilities.triage.rules import RULE_REGISTRY @@ -36,7 +37,7 @@ class TriageRulesetAdmin(DataspacedAdmin): form = TriageRulesetForm list_display = [ "name", - "action", + "get_action_label", "precedence", "get_enabled_rules", "description", @@ -46,6 +47,10 @@ class TriageRulesetAdmin(DataspacedAdmin): list_filter = DataspacedAdmin.list_filter + ("enabled",) search_fields = ["name"] + @admin.display(description="Action") + def get_action_label(self, obj): + return dict(TriageAction.choices).get(obj.action, obj.action) + @admin.display(description="Enabled rules") def get_enabled_rules(self, obj): lines = [] @@ -112,7 +117,7 @@ class TriageDecisionAdmin(DataspacedAdmin): list_display = [ "product", "ruleset", - "action", + "get_action_label", "get_matched_rules", "detected_date", "last_checked", @@ -123,12 +128,16 @@ class TriageDecisionAdmin(DataspacedAdmin): readonly_fields = DataspacedAdmin.readonly_fields + ( "product", "ruleset", - "action", + "get_action_label", "matched_rules", "detected_date", "last_checked", ) + @admin.display(description="Action") + def get_action_label(self, obj): + return dict(TriageAction.choices).get(obj.action, obj.action) + @admin.display(description="Matched rules") def get_matched_rules(self, obj): labels = [ diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py index e5e8b675..851caddd 100644 --- a/vulnerabilities/triage/forms.py +++ b/vulnerabilities/triage/forms.py @@ -9,11 +9,17 @@ from django import forms from dje.forms import DataspacedAdminForm +from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRuleset from vulnerabilities.triage.rules import RULE_REGISTRY class TriageRulesetForm(DataspacedAdminForm): + action = forms.ChoiceField( + choices=[("", "---------")] + list(TriageAction.choices), + required=False, + ) + class Meta: model = TriageRuleset fields = ["name", "description", "action", "precedence", "enabled"] diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index 3d09db4e..b6324eee 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -10,6 +10,7 @@ from django.core.management.base import CommandError from dje.models import Dataspace +from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRuleset """ @@ -20,7 +21,7 @@ { "name": "Critical Exploited Vulnerability", "description": "Packages with a critical vulnerability and a known active exploit.", - "action": TriageRuleset.Action.UPGRADE, + "action": TriageAction.UPGRADE, "precedence": 400, "rules_config": { "risk_score": {"is_active": True, "min_risk_score": 8.0}, @@ -30,7 +31,7 @@ { "name": "Active Exploit", "description": "Packages with a known exploit available, regardless of severity.", - "action": TriageRuleset.Action.UPGRADE, + "action": TriageAction.UPGRADE, "precedence": 300, "rules_config": { "exploited_vulnerability": {"is_active": True}, @@ -41,7 +42,7 @@ "description": ( "Packages with a vulnerability confirmed as reachable in the product context." ), - "action": TriageRuleset.Action.APPLY_PATCH, + "action": TriageAction.APPLY_PATCH, "precedence": 250, "rules_config": { "reachable_vulnerability": {"is_active": True}, @@ -50,7 +51,7 @@ { "name": "Critical Vulnerability", "description": "Packages with a critical-severity vulnerability and no known exploit.", - "action": TriageRuleset.Action.APPLY_PATCH, + "action": TriageAction.APPLY_PATCH, "precedence": 200, "rules_config": { "risk_score": {"is_active": True, "min_risk_score": 8.0}, @@ -62,7 +63,7 @@ "Packages with a critical-severity vulnerability left unaddressed" " for more than 30 days." ), - "action": TriageRuleset.Action.APPLY_PATCH, + "action": TriageAction.APPLY_PATCH, "precedence": 150, "rules_config": { "stale_vulnerability": {"is_active": True, "min_risk_score": 8.0, "max_days": 30}, @@ -71,7 +72,7 @@ { "name": "Unresolved Vulnerability", "description": "Packages with vulnerabilities that have no completed triage analysis.", - "action": TriageRuleset.Action.FORENSIC_ANALYSIS, + "action": TriageAction.FORENSIC_ANALYSIS, "precedence": 100, "rules_config": { "unresolved_vulnerability": {"is_active": True}, @@ -80,7 +81,7 @@ { "name": "Dev-Only Vulnerable Package", "description": "Packages not deployed in production that are affected by vulnerabilities.", - "action": TriageRuleset.Action.NOTIFY, + "action": TriageAction.NOTIFY, "precedence": 50, "rules_config": { "dev_only_vulnerable_package": {"is_active": True}, diff --git a/vulnerabilities/triage/migrations/0004_alter_triagedecision_action_and_more.py b/vulnerabilities/triage/migrations/0004_alter_triagedecision_action_and_more.py new file mode 100644 index 00000000..412c93fd --- /dev/null +++ b/vulnerabilities/triage/migrations/0004_alter_triagedecision_action_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-08-06 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vulnerabilities_triage', '0003_triagedecision'), + ] + + operations = [ + migrations.AlterField( + model_name='triagedecision', + name='action', + field=models.CharField(help_text='Recommended action at the time of evaluation.', max_length=50), + ), + migrations.AlterField( + model_name='triageruleset', + name='action', + field=models.CharField(blank=True, help_text="Action recommended when this ruleset's conditions are met.", max_length=50), + ), + ] diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index 42045d50..8f2aff88 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -14,17 +14,18 @@ from dje.models import ProductSecuredQuerySet -class TriageRuleset(DataspacedModel): - class Action(models.TextChoices): - UPGRADE = "upgrade", _("Upgrade Package") - APPLY_PATCH = "apply_patch", _("Apply Patch") - FORENSIC_ANALYSIS = "forensic_analysis", _("Forensic Analysis") - REACHABILITY_ANALYSIS = "reachability_analysis", _("Reachability Analysis") - CHANGE_CONFIG = "change_config", _("Change Configuration") - REPLACE_PACKAGE = "replace_package", _("Replace Package") - NOTIFY = "notify", _("Notify") - CREATE_REQUEST = "create_request", _("Create DejaCode Request") +class TriageAction(models.TextChoices): + UPGRADE = "upgrade", _("Upgrade Package") + APPLY_PATCH = "apply_patch", _("Apply Patch") + FORENSIC_ANALYSIS = "forensic_analysis", _("Forensic Analysis") + REACHABILITY_ANALYSIS = "reachability_analysis", _("Reachability Analysis") + CHANGE_CONFIG = "change_config", _("Change Configuration") + REPLACE_PACKAGE = "replace_package", _("Replace Package") + NOTIFY = "notify", _("Notify") + CREATE_REQUEST = "create_request", _("Create DejaCode Request") + +class TriageRuleset(DataspacedModel): name = models.CharField( max_length=100, help_text=_("Short name identifying this triage ruleset."), @@ -35,7 +36,6 @@ class Action(models.TextChoices): ) action = models.CharField( max_length=50, - choices=Action.choices, blank=True, help_text=_("Action recommended when this ruleset's conditions are met."), ) @@ -81,7 +81,6 @@ class TriageDecision(DataspacedModel): ) action = models.CharField( max_length=50, - choices=TriageRuleset.Action.choices, help_text=_("Recommended action at the time of evaluation."), ) matched_rules = models.JSONField( From 82329bc921ca584161617ce515c6b9488e3b8a35 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 7 Aug 2026 08:56:09 +0400 Subject: [PATCH 26/90] refactoring toward a ProductPackageTriage model Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 44 ++++++++--- vulnerabilities/triage/engine.py | 55 +++++++++++--- .../management/commands/evaluate_triage.py | 34 ++------- ...ductpackagetriage_delete_triagedecision.py | 40 ++++++++++ vulnerabilities/triage/models.py | 45 +++++++---- vulnerabilities/triage/rules.py | 75 +++++++------------ 6 files changed, 179 insertions(+), 114 deletions(-) create mode 100644 vulnerabilities/triage/migrations/0005_productpackagetriage_delete_triagedecision.py diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 1c11a915..79569898 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -13,8 +13,8 @@ from dje.admin import DataspacedAdmin from dje.admin import dejacode_site from vulnerabilities.triage.forms import TriageRulesetForm +from vulnerabilities.triage.models import ProductPackageTriage from vulnerabilities.triage.models import TriageAction -from vulnerabilities.triage.models import TriageDecision from vulnerabilities.triage.models import TriageRuleset from vulnerabilities.triage.rules import RULE_REGISTRY @@ -103,19 +103,22 @@ def get_fieldsets(self, request, obj=None): return base_fieldsets + rule_fieldsets -@admin.register(TriageDecision, site=dejacode_site) -class TriageDecisionAdmin(DataspacedAdmin): +@admin.register(ProductPackageTriage, site=dejacode_site) +class ProductPackageTriageAdmin(DataspacedAdmin): short_description = ( - "A Triage Decision records the result of evaluating a ruleset against a product." + "A Package Triage record stores the recommended action for a specific package" + " usage within a product, as determined by the evaluation engine." ) long_description = ( - "Triage Decisions are created automatically by the evaluation engine. Each record" - " stores the recommended action, the rules that matched, and the dates of first" - " detection and last check. These records are read-only in the admin." + "Package Triage records are created and updated automatically when a ruleset is" + " evaluated against a product. Each record links a specific package usage to the" + " ruleset that triggered it, captures which rules fired, and timestamps the first" + " detection and most recent check. These records are read-only in the admin." ) list_display = [ - "product", + "get_product", + "get_package", "ruleset", "get_action_label", "get_matched_rules", @@ -124,9 +127,13 @@ class TriageDecisionAdmin(DataspacedAdmin): "get_dataspace", ] list_filter = DataspacedAdmin.list_filter + ("action", "ruleset") - search_fields = ["product__name", "ruleset__name"] + search_fields = [ + "product_package__package__name", + "product_package__product__name", + "ruleset__name", + ] readonly_fields = DataspacedAdmin.readonly_fields + ( - "product", + "product_package", "ruleset", "get_action_label", "matched_rules", @@ -134,6 +141,14 @@ class TriageDecisionAdmin(DataspacedAdmin): "last_checked", ) + @admin.display(description="Product", ordering="product_package__product__name") + def get_product(self, obj): + return obj.product_package.product + + @admin.display(description="Package", ordering="product_package__package__name") + def get_package(self, obj): + return obj.product_package.package + @admin.display(description="Action") def get_action_label(self, obj): return dict(TriageAction.choices).get(obj.action, obj.action) @@ -154,8 +169,15 @@ def get_queryset(self, request): super() .get_queryset(request) .product_secured(request.user, "view_product") - .select_related("product", "ruleset") + .select_related( + "product_package__product", + "product_package__package", + "ruleset", + ) ) def has_add_permission(self, request): return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index dcc05c3f..2197c9be 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -6,17 +6,16 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +from vulnerabilities.triage.models import ProductPackageTriage from vulnerabilities.triage.rules import RULE_REGISTRY -def evaluate_ruleset(ruleset, product): +def collect_matches(ruleset, product): """ - Evaluate a TriageRuleset against a product. - - Iterates all active rules in the ruleset. Returns a dict with the recommended - action and the list of rule types that fired, or None if no rule fires. + Return a dict mapping each matching ProductPackage PK to the list of rule + types that fired for it, for all active rules in the ruleset. """ - matched_rules = [] + matched_rules_per_package_id = {} for rule_type, config in ruleset.rules_config.items(): if not config.get("is_active"): @@ -27,8 +26,44 @@ def evaluate_ruleset(ruleset, product): continue parameters = {key: value for key, value in config.items() if key != "is_active"} - if handler.count_matches(product=product, parameters=parameters) > 0: - matched_rules.append(rule_type) + matching_package_ids = handler.get_matching_packages( + product=product, + parameters=parameters, + ).values_list("pk", flat=True) + + for package_id in matching_package_ids: + matched_rules_per_package_id.setdefault(package_id, []).append(rule_type) - if matched_rules: - return {"action": ruleset.action, "matched_rules": matched_rules} + return matched_rules_per_package_id + + +def sync_triage_records(ruleset, product, matched_rules_per_package_id): + """ + Create or update one ProductPackageTriage record per matching package, then + delete records for packages that no longer match any rule in the ruleset. + """ + for product_package_id, matched_rules in matched_rules_per_package_id.items(): + ProductPackageTriage.objects.update_or_create( + product_package_id=product_package_id, + ruleset=ruleset, + defaults={ + "action": ruleset.action, + "matched_rules": matched_rules, + "dataspace": ruleset.dataspace, + }, + ) + + ProductPackageTriage.objects.filter( + ruleset=ruleset, + product_package__product=product, + ).exclude(product_package_id__in=matched_rules_per_package_id.keys()).delete() + + +def evaluate_ruleset(ruleset, product): + """Evaluate a TriageRuleset against a product and persist the results.""" + matched_rules_per_package_id = collect_matches(ruleset=ruleset, product=product) + sync_triage_records( + ruleset=ruleset, + product=product, + matched_rules_per_package_id=matched_rules_per_package_id, + ) diff --git a/vulnerabilities/triage/management/commands/evaluate_triage.py b/vulnerabilities/triage/management/commands/evaluate_triage.py index 4de763a1..0ba92188 100644 --- a/vulnerabilities/triage/management/commands/evaluate_triage.py +++ b/vulnerabilities/triage/management/commands/evaluate_triage.py @@ -12,7 +12,7 @@ from dje.models import Dataspace from vulnerabilities.triage.engine import evaluate_ruleset -from vulnerabilities.triage.models import TriageDecision +from vulnerabilities.triage.models import ProductPackageTriage from vulnerabilities.triage.models import TriageRuleset """ @@ -51,38 +51,14 @@ def handle(self, *args, **options): self.stdout.write("No products found.") return - created_count = 0 - updated_count = 0 - no_match_count = 0 - for product in products: + self.stdout.write(f" {product}") for ruleset in rulesets: - result = evaluate_ruleset(ruleset=ruleset, product=product) - if not result: - no_match_count += 1 - continue - - _, created = TriageDecision.objects.update_or_create( - dataspace=dataspace, - product=product, - ruleset=ruleset, - defaults={ - "action": result["action"], - "matched_rules": result["matched_rules"], - }, - ) - - label = f"{product} -> {ruleset}" - if created: - created_count += 1 - self.stdout.write(f" [new] {label}: {result['action']}") - else: - updated_count += 1 - self.stdout.write(f" [updated] {label}: {result['action']}") + evaluate_ruleset(ruleset=ruleset, product=product) + total = ProductPackageTriage.objects.filter(dataspace=dataspace).count() self.stdout.write( self.style.SUCCESS( - f"Done: {created_count} created, {updated_count} updated," - f" {no_match_count} no match." + f"Done: {total} package triage record(s) active in dataspace '{dataspace_name}'." ) ) diff --git a/vulnerabilities/triage/migrations/0005_productpackagetriage_delete_triagedecision.py b/vulnerabilities/triage/migrations/0005_productpackagetriage_delete_triagedecision.py new file mode 100644 index 00000000..792358b5 --- /dev/null +++ b/vulnerabilities/triage/migrations/0005_productpackagetriage_delete_triagedecision.py @@ -0,0 +1,40 @@ +# Generated by Django 6.0.6 on 2026-08-06 12:37 + +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', '0019_productaffectedbyvulnerability_detected_date'), + ('vulnerabilities_triage', '0004_alter_triagedecision_action_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ProductPackageTriage', + 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')), + ('action', models.CharField(help_text='Recommended action captured at the time of evaluation.', max_length=50)), + ('matched_rules', models.JSONField(default=list, help_text='Rules that fired for this package during evaluation.')), + ('detected_date', models.DateTimeField(auto_now_add=True, help_text='Date and time when this recommendation was first generated.')), + ('last_checked', models.DateTimeField(auto_now=True, help_text='Date and time of the last evaluation.')), + ('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_package', models.ForeignKey(help_text='The specific package usage that triggered this recommendation.', on_delete=django.db.models.deletion.CASCADE, related_name='triage_records', to='product_portfolio.productpackage')), + ('ruleset', models.ForeignKey(help_text='The ruleset that produced this action.', on_delete=django.db.models.deletion.CASCADE, related_name='triage_records', to='vulnerabilities_triage.triageruleset')), + ], + options={ + 'ordering': ['-detected_date'], + 'unique_together': {('dataspace', 'uuid'), ('product_package', 'ruleset')}, + }, + bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), + ), + migrations.DeleteModel( + name='TriageDecision', + ), + ] diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index 8f2aff88..e8c76dda 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -6,6 +6,7 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +from django.apps import apps from django.db import models from django.utils.translation import gettext_lazy as _ @@ -64,44 +65,58 @@ def __str__(self): return self.name -class TriageDecision(DataspacedModel): - """Stores the result of evaluating a TriageRuleset against a product.""" +class ProductPackageTriageQuerySet(ProductSecuredQuerySet): + def product_secured(self, user=None, perms="view_product"): + """ + Filter by product object permission through the product_package relation. - product = models.ForeignKey( - to="product_portfolio.Product", + The base implementation filters on a direct `product` FK, which does not exist + on this model. The product is reached via product_package__product instead. + """ + if not user: + return self.none() + Product = apps.get_model("product_portfolio", "Product") + product_qs = Product.objects.get_queryset(user, perms) + return self.filter(product_package__product__in=product_qs) + + +class ProductPackageTriage(DataspacedModel): + """Stores the triage recommendation for a specific package usage within a product.""" + + product_package = models.ForeignKey( + to="product_portfolio.ProductPackage", on_delete=models.CASCADE, - related_name="triage_decisions", - help_text=_("The product against which this ruleset was evaluated."), + related_name="triage_records", + help_text=_("The specific package usage that triggered this recommendation."), ) ruleset = models.ForeignKey( to="vulnerabilities_triage.TriageRuleset", on_delete=models.CASCADE, - related_name="triage_decisions", + related_name="triage_records", help_text=_("The ruleset that produced this action."), ) action = models.CharField( max_length=50, - help_text=_("Recommended action at the time of evaluation."), + help_text=_("Recommended action captured at the time of evaluation."), ) matched_rules = models.JSONField( default=list, - help_text=_("List of rule types that detected violations during this evaluation."), + help_text=_("Rules that fired for this package during evaluation."), ) - - objects = DataspacedManager.from_queryset(ProductSecuredQuerySet)() - detected_date = models.DateTimeField( auto_now_add=True, - help_text=_("Date and time when this action was first recommended."), + help_text=_("Date and time when this recommendation was first generated."), ) last_checked = models.DateTimeField( auto_now=True, help_text=_("Date and time of the last evaluation."), ) + objects = DataspacedManager.from_queryset(ProductPackageTriageQuerySet)() + class Meta: - unique_together = (("dataspace", "uuid"), ("product", "ruleset")) + unique_together = [("product_package", "ruleset"), ("dataspace", "uuid")] ordering = ["-detected_date"] def __str__(self): - return f"{self.ruleset} / {self.product}: {self.action}" + return f"{self.product_package} / {self.ruleset}: {self.action}" diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index 1e398f0c..c472ff58 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -28,7 +28,7 @@ class BaseTriageRule(BaseRule): parameters_schema = {} - def count_matches(self, product, parameters=None): + def get_matching_packages(self, product, parameters=None): raise NotImplementedError @@ -43,19 +43,15 @@ class RiskScoreTriageRule(BaseTriageRule): }, } - def count_matches(self, product, parameters=None): + def get_matching_packages(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") min_risk_score = (parameters or {}).get( "min_risk_score", self.parameters_schema["min_risk_score"]["default"] ) - return ( - ProductPackage.objects.filter( - product=product, - package__affected_by_vulnerabilities__risk_score__gte=min_risk_score, - ) - .distinct() - .count() - ) + return ProductPackage.objects.filter( + product=product, + package__affected_by_vulnerabilities__risk_score__gte=min_risk_score, + ).distinct() class WeightedRiskTriageRule(BaseTriageRule): @@ -69,20 +65,16 @@ class WeightedRiskTriageRule(BaseTriageRule): }, } - def count_matches(self, product, parameters=None): + def get_matching_packages(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") min_weighted_risk_score = (parameters or {}).get( "min_weighted_risk_score", self.parameters_schema["min_weighted_risk_score"]["default"], ) - return ( - ProductPackage.objects.filter( - product=product, - weighted_risk_score__gte=min_weighted_risk_score, - ) - .distinct() - .count() - ) + return ProductPackage.objects.filter( + product=product, + weighted_risk_score__gte=min_weighted_risk_score, + ).distinct() class ExploitedVulnerabilityTriageRule(BaseTriageRule): @@ -90,17 +82,13 @@ class ExploitedVulnerabilityTriageRule(BaseTriageRule): label = "Exploited Vulnerability" description = "Packages with a vulnerability for which a known exploit is available." - def count_matches(self, product, parameters=None): + def get_matching_packages(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") # exploitability == 2.0 means known exploits are available - return ( - ProductPackage.objects.filter( - product=product, - package__affected_by_vulnerabilities__exploitability=2.0, - ) - .distinct() - .count() - ) + return ProductPackage.objects.filter( + product=product, + package__affected_by_vulnerabilities__exploitability=2.0, + ).distinct() class ReachableVulnerabilityTriageRule(BaseTriageRule): @@ -108,7 +96,7 @@ class ReachableVulnerabilityTriageRule(BaseTriageRule): label = "Reachable Vulnerability" description = "Packages with a vulnerability confirmed as reachable in the product context." - def count_matches(self, product, parameters=None): + def get_matching_packages(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") VulnerabilityAnalysis = apps.get_model("vulnerabilities", "vulnerabilityanalysis") reachable_analysis = VulnerabilityAnalysis.objects.filter( @@ -119,7 +107,6 @@ def count_matches(self, product, parameters=None): ProductPackage.objects.filter(product=product) .filter(Exists(reachable_analysis)) .distinct() - .count() ) @@ -128,7 +115,7 @@ class UnresolvedVulnerabilityTriageRule(BaseTriageRule): label = "Unresolved Vulnerability" description = "Packages with known vulnerabilities that have no completed analysis." - def count_matches(self, product, parameters=None): + def get_matching_packages(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") PackageAffectedByVulnerability = apps.get_model( "component_catalog", "packageaffectedbyvulnerability" @@ -148,7 +135,6 @@ def count_matches(self, product, parameters=None): ProductPackage.objects.filter(product=product) .filter(Exists(unresolved_link)) .distinct() - .count() ) @@ -172,7 +158,7 @@ class StaleVulnerabilityTriageRule(BaseTriageRule): }, } - def count_matches(self, product, parameters=None): + def get_matching_packages(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") PackageAffectedByVulnerability = apps.get_model( "component_catalog", "packageaffectedbyvulnerability" @@ -198,12 +184,7 @@ def count_matches(self, product, parameters=None): .annotate(has_terminal=Exists(terminal_analysis)) .filter(has_terminal=False) ) - return ( - ProductPackage.objects.filter(product=product) - .filter(Exists(stale_link)) - .distinct() - .count() - ) + return ProductPackage.objects.filter(product=product).filter(Exists(stale_link)).distinct() class DevOnlyPackageTriageRule(BaseTriageRule): @@ -211,17 +192,13 @@ class DevOnlyPackageTriageRule(BaseTriageRule): label = "Dev-Only Vulnerable Package" description = "Packages not deployed in production that are affected by vulnerabilities." - def count_matches(self, product, parameters=None): + def get_matching_packages(self, product, parameters=None): ProductPackage = apps.get_model("product_portfolio", "productpackage") - return ( - ProductPackage.objects.filter( - product=product, - is_deployed=False, - package__affected_by_vulnerabilities__isnull=False, - ) - .distinct() - .count() - ) + return ProductPackage.objects.filter( + product=product, + is_deployed=False, + package__affected_by_vulnerabilities__isnull=False, + ).distinct() RULE_REGISTRY = { From 9bf8473217b374ce40aa6be68b11f8d06baae6f5 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 7 Aug 2026 09:33:45 +0400 Subject: [PATCH 27/90] delete all package triage records when a ruleset is disabled Signed-off-by: tdruez --- vulnerabilities/triage/apps.py | 3 +++ vulnerabilities/triage/signals.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 vulnerabilities/triage/signals.py diff --git a/vulnerabilities/triage/apps.py b/vulnerabilities/triage/apps.py index a7075c77..9f2406cb 100644 --- a/vulnerabilities/triage/apps.py +++ b/vulnerabilities/triage/apps.py @@ -14,3 +14,6 @@ class TriageConfig(AppConfig): name = "vulnerabilities.triage" label = "vulnerabilities_triage" verbose_name = _("Vulnerabilities Triage") + + def ready(self): + import vulnerabilities.triage.signals # noqa: F401 diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py new file mode 100644 index 00000000..ed5d7960 --- /dev/null +++ b/vulnerabilities/triage/signals.py @@ -0,0 +1,17 @@ +# +# 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.db.models.signals import post_save +from django.dispatch import receiver + + +@receiver(post_save, sender="vulnerabilities_triage.TriageRuleset") +def delete_triage_records_on_disable(sender, instance, **kwargs): + """Delete all package triage records when a ruleset is disabled.""" + if not instance.enabled: + instance.triage_records.all().delete() From 52cdc588e1460ce3e55e580b5e4bcb543720bf4d Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 7 Aug 2026 09:34:01 +0400 Subject: [PATCH 28/90] add primary_actions qs method Signed-off-by: tdruez --- vulnerabilities/triage/models.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index e8c76dda..f4be6a04 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -79,6 +79,19 @@ def product_secured(self, user=None, perms="view_product"): product_qs = Product.objects.get_queryset(user, perms) return self.filter(product_package__product__in=product_qs) + def primary_actions(self): + """ + Return one record per product_package: the highest-precedence active ruleset. + + Uses PostgreSQL DISTINCT ON to pick the winning ruleset when multiple rulesets + fire for the same package. Chain after a product filter to scope the results. + """ + return ( + self.filter(ruleset__enabled=True) + .order_by("product_package", "-ruleset__precedence") + .distinct("product_package") + ) + class ProductPackageTriage(DataspacedModel): """Stores the triage recommendation for a specific package usage within a product.""" From 286482f2264ca7d8c084e4794dc1aeef2052a990 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 7 Aug 2026 10:03:45 +0400 Subject: [PATCH 29/90] rename model to TriageRecord Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 6 +++--- vulnerabilities/triage/engine.py | 8 ++++---- .../management/commands/evaluate_triage.py | 4 ++-- ...ename_productpackagetriage_triagerecord.py | 19 +++++++++++++++++++ vulnerabilities/triage/models.py | 6 +++--- 5 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 vulnerabilities/triage/migrations/0006_rename_productpackagetriage_triagerecord.py diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 79569898..600d1ddb 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -13,8 +13,8 @@ from dje.admin import DataspacedAdmin from dje.admin import dejacode_site from vulnerabilities.triage.forms import TriageRulesetForm -from vulnerabilities.triage.models import ProductPackageTriage from vulnerabilities.triage.models import TriageAction +from vulnerabilities.triage.models import TriageRecord from vulnerabilities.triage.models import TriageRuleset from vulnerabilities.triage.rules import RULE_REGISTRY @@ -103,8 +103,8 @@ def get_fieldsets(self, request, obj=None): return base_fieldsets + rule_fieldsets -@admin.register(ProductPackageTriage, site=dejacode_site) -class ProductPackageTriageAdmin(DataspacedAdmin): +@admin.register(TriageRecord, site=dejacode_site) +class TriageRecordAdmin(DataspacedAdmin): short_description = ( "A Package Triage record stores the recommended action for a specific package" " usage within a product, as determined by the evaluation engine." diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index 2197c9be..699e4b00 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -6,7 +6,7 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # -from vulnerabilities.triage.models import ProductPackageTriage +from vulnerabilities.triage.models import TriageRecord from vulnerabilities.triage.rules import RULE_REGISTRY @@ -39,11 +39,11 @@ def collect_matches(ruleset, product): def sync_triage_records(ruleset, product, matched_rules_per_package_id): """ - Create or update one ProductPackageTriage record per matching package, then + Create or update one TriageRecord record per matching package, then delete records for packages that no longer match any rule in the ruleset. """ for product_package_id, matched_rules in matched_rules_per_package_id.items(): - ProductPackageTriage.objects.update_or_create( + TriageRecord.objects.update_or_create( product_package_id=product_package_id, ruleset=ruleset, defaults={ @@ -53,7 +53,7 @@ def sync_triage_records(ruleset, product, matched_rules_per_package_id): }, ) - ProductPackageTriage.objects.filter( + TriageRecord.objects.filter( ruleset=ruleset, product_package__product=product, ).exclude(product_package_id__in=matched_rules_per_package_id.keys()).delete() diff --git a/vulnerabilities/triage/management/commands/evaluate_triage.py b/vulnerabilities/triage/management/commands/evaluate_triage.py index 0ba92188..cd766fe4 100644 --- a/vulnerabilities/triage/management/commands/evaluate_triage.py +++ b/vulnerabilities/triage/management/commands/evaluate_triage.py @@ -12,7 +12,7 @@ from dje.models import Dataspace from vulnerabilities.triage.engine import evaluate_ruleset -from vulnerabilities.triage.models import ProductPackageTriage +from vulnerabilities.triage.models import TriageRecord from vulnerabilities.triage.models import TriageRuleset """ @@ -56,7 +56,7 @@ def handle(self, *args, **options): for ruleset in rulesets: evaluate_ruleset(ruleset=ruleset, product=product) - total = ProductPackageTriage.objects.filter(dataspace=dataspace).count() + total = TriageRecord.objects.filter(dataspace=dataspace).count() self.stdout.write( self.style.SUCCESS( f"Done: {total} package triage record(s) active in dataspace '{dataspace_name}'." diff --git a/vulnerabilities/triage/migrations/0006_rename_productpackagetriage_triagerecord.py b/vulnerabilities/triage/migrations/0006_rename_productpackagetriage_triagerecord.py new file mode 100644 index 00000000..6e1b5c78 --- /dev/null +++ b/vulnerabilities/triage/migrations/0006_rename_productpackagetriage_triagerecord.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.6 on 2026-08-07 05:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dje', '0016_dataspaceconfiguration_policy_rules_config'), + ('product_portfolio', '0019_productaffectedbyvulnerability_detected_date'), + ('vulnerabilities_triage', '0005_productpackagetriage_delete_triagedecision'), + ] + + operations = [ + migrations.RenameModel( + old_name='ProductPackageTriage', + new_name='TriageRecord', + ), + ] diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index f4be6a04..d7d28254 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -65,7 +65,7 @@ def __str__(self): return self.name -class ProductPackageTriageQuerySet(ProductSecuredQuerySet): +class TriageRecordQuerySet(ProductSecuredQuerySet): def product_secured(self, user=None, perms="view_product"): """ Filter by product object permission through the product_package relation. @@ -93,7 +93,7 @@ def primary_actions(self): ) -class ProductPackageTriage(DataspacedModel): +class TriageRecord(DataspacedModel): """Stores the triage recommendation for a specific package usage within a product.""" product_package = models.ForeignKey( @@ -125,7 +125,7 @@ class ProductPackageTriage(DataspacedModel): help_text=_("Date and time of the last evaluation."), ) - objects = DataspacedManager.from_queryset(ProductPackageTriageQuerySet)() + objects = DataspacedManager.from_queryset(TriageRecordQuerySet)() class Meta: unique_together = [("product_package", "ruleset"), ("dataspace", "uuid")] From e5994adce11c8118012ecaee8cbe9e41a3cee6da Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 7 Aug 2026 13:14:19 +0400 Subject: [PATCH 30/90] refine admin ordering Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 600d1ddb..fd25f668 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -140,6 +140,12 @@ class TriageRecordAdmin(DataspacedAdmin): "detected_date", "last_checked", ) + ordering = [ + "product_package__product__name", + "product_package__product__version", + "product_package__package__name", + "-ruleset__precedence", + ] @admin.display(description="Product", ordering="product_package__product__name") def get_product(self, obj): From 293a6b4ca2d29cb13fcd7479bc4ab1214ea08391 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 7 Aug 2026 14:14:58 +0400 Subject: [PATCH 31/90] add Triage tab in the product details view Signed-off-by: tdruez --- .../product_portfolio/tabs/tab_triage.html | 58 ++++++++++++ product_portfolio/urls.py | 2 + product_portfolio/views.py | 91 +++++++++++++++++++ vulnerabilities/triage/models.py | 21 +++-- 4 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 product_portfolio/templates/product_portfolio/tabs/tab_triage.html diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_triage.html b/product_portfolio/templates/product_portfolio/tabs/tab_triage.html new file mode 100644 index 00000000..3cfa886f --- /dev/null +++ b/product_portfolio/templates/product_portfolio/tabs/tab_triage.html @@ -0,0 +1,58 @@ +{% load i18n %} + +
+
+ {{ total_count }} package{{ total_count|pluralize }} with pending triage action{{ total_count|pluralize }} +
+ {% if page_obj.paginator.num_pages > 1 %} + {% include 'pagination/object_list_pagination.html' with hx_target=tab_id_html %} + {% endif %} +
+ +{% if page_obj.object_list %} + + + + + + + + + + + + {% for record in page_obj.object_list %} + + + + + + + + {% endfor %} + +
{% trans "Package" %}{% trans "Recommended action" %}{% trans "Ruleset" %}{% trans "Rules triggered" %}{% trans "Detected" %}
+ + + {{ record.product_package.package }} + + + + + {{ record.action_label }} + + {{ record.ruleset.name }} + {% for label in record.rule_labels %} +
{{ label }}
+ {% endfor %} +
{{ record.detected_date|date:"Y-m-d" }}
+ {% if page_obj.paginator.num_pages > 1 %} + {% include 'pagination/object_list_pagination.html' with hx_target=tab_id_html %} + {% endif %} +{% else %} +
+ +
{% trans "No pending triage actions" %}
+
{% trans "All packages are within acceptable risk parameters." %}
+
+{% endif %} diff --git a/product_portfolio/urls.py b/product_portfolio/urls.py index 8231594a..a87af947 100644 --- a/product_portfolio/urls.py +++ b/product_portfolio/urls.py @@ -34,6 +34,7 @@ from product_portfolio.views import ProductTabDependenciesView from product_portfolio.views import ProductTabInventoryView from product_portfolio.views import ProductTabLicensesView +from product_portfolio.views import ProductTabTriageView from product_portfolio.views import ProductTabVulnerabilitiesView from product_portfolio.views import ProductTreeComparisonView from product_portfolio.views import ProductUpdateView @@ -152,6 +153,7 @@ def product_path(path_segment, view): *product_path("tab_dependencies", ProductTabDependenciesView.as_view()), *product_path("tab_licenses", ProductTabLicensesView.as_view()), *product_path("tab_vulnerabilities", ProductTabVulnerabilitiesView.as_view()), + *product_path("tab_triage", ProductTabTriageView.as_view()), *product_path("tab_activity", ProductTabActivityView.as_view()), *product_path("tab_inventory", ProductTabInventoryView.as_view()), *product_path("tab_compliance", ProductTabComplianceView.as_view()), diff --git a/product_portfolio/views.py b/product_portfolio/views.py index baf3c463..cbe6b4eb 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -150,6 +150,9 @@ from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.models import get_risk_level +from vulnerabilities.triage.models import TriageAction +from vulnerabilities.triage.models import TriageRecord +from vulnerabilities.triage.rules import RULE_REGISTRY as TRIAGE_RULE_REGISTRY class BaseProductViewMixin: @@ -295,6 +298,7 @@ class ProductDetailsView( ], }, "vulnerabilities": {}, + "triage": {}, "codebase": { "fields": [ "path", @@ -666,6 +670,34 @@ def tab_vulnerabilities(self): "fields": [(None, tab_context, None, template)], } + def tab_triage(self): + product = self.object + + triage_count = ( + TriageRecord.objects.filter(product_package__product=product).primary_actions().count() + ) + + badge_class = "bg-primary-subtle text-primary-emphasis" + if triage_count > 0: + badge_class = "bg-warning-subtle text-warning-emphasis" + + label = f'Triage {triage_count}' + + tab_view_url = product.get_url("tab_triage") + if full_query_string := self.request.META["QUERY_STRING"]: + tab_view_url += f"?{full_query_string}" + + template = "tabs/tab_async_loader.html" + tab_context = { + "tab_view_url": tab_view_url, + "tab_object_name": "triage actions", + } + + return { + "label": mark_safe(label), + "fields": [(None, tab_context, None, template)], + } + def tab_codebase(self): codebaseresources_count = self.object.codebaseresources.count() if not codebaseresources_count: @@ -1328,6 +1360,65 @@ def get_context_data(self, **kwargs): return context_data +class ProductTabTriageView( + LoginRequiredMixin, + BaseProductViewMixin, + PaginationMixin, + TabContentView, +): + template_name = "product_portfolio/tabs/tab_triage.html" + paginate_by = 50 + query_dict_page_param = "triage-page" + tab_id = "triage" + + def get_context_data(self, **kwargs): + product = self.object + + action_labels = dict(TriageAction.choices) + rule_labels = { + rule_type: handler.label for rule_type, handler in TRIAGE_RULE_REGISTRY.items() + } + + triage_qs = ( + TriageRecord.objects.filter(product_package__product=product) + .primary_actions() + .select_related( + "product_package__package", + "ruleset", + ) + ) + total_count = triage_qs.count() + + paginator = Paginator(triage_qs, self.paginate_by) + page_number = self.request.GET.get(self.query_dict_page_param) + page_obj = paginator.get_page(page_number) + + for record in page_obj.object_list: + record.action_label = action_labels.get(record.action, record.action) + record.rule_labels = [ + rule_labels.get(rule_type, rule_type) for rule_type in record.matched_rules + ] + + context_data = super().get_context_data(**kwargs) + context_data.update( + { + "page_obj": page_obj, + "total_count": total_count, + } + ) + + if page_obj: + previous_url, next_url = self.get_previous_next(page_obj) + context_data.update( + { + "previous_url": (previous_url or "") + f"#{self.tab_id}", + "next_url": (next_url or "") + f"#{self.tab_id}", + } + ) + + return context_data + + class ProductTabActivityView( LoginRequiredMixin, BaseProductViewMixin, diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index d7d28254..4af753fa 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -8,6 +8,8 @@ from django.apps import apps from django.db import models +from django.db.models import OuterRef +from django.db.models import Subquery from django.utils.translation import gettext_lazy as _ from dje.models import DataspacedManager @@ -83,13 +85,20 @@ def primary_actions(self): """ Return one record per product_package: the highest-precedence active ruleset. - Uses PostgreSQL DISTINCT ON to pick the winning ruleset when multiple rulesets - fire for the same package. Chain after a product filter to scope the results. + Uses a correlated subquery to find the winning ruleset per package rather than + DISTINCT ON, which breaks under Django's COUNT wrapping and select_related JOINs. """ - return ( - self.filter(ruleset__enabled=True) - .order_by("product_package", "-ruleset__precedence") - .distinct("product_package") + winning_ruleset_id = ( + self.model.objects.filter( + product_package=OuterRef("product_package"), + ruleset__enabled=True, + ) + .order_by("-ruleset__precedence") + .values("ruleset_id")[:1] + ) + return self.filter( + ruleset__enabled=True, + ruleset_id=Subquery(winning_ruleset_id), ) From 364f8f84ba3820760f09fe053d5bb490add12ad4 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 10 Aug 2026 09:52:57 +0400 Subject: [PATCH 32/90] progress on the triage tab Signed-off-by: tdruez --- .../product_portfolio/tabs/tab_triage.html | 99 ++++++++++++++----- product_portfolio/views.py | 41 ++++++-- 2 files changed, 109 insertions(+), 31 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_triage.html b/product_portfolio/templates/product_portfolio/tabs/tab_triage.html index 3cfa886f..a63af764 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_triage.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_triage.html @@ -10,38 +10,85 @@ {% if page_obj.object_list %} - - +
+ - - - - - + + + + {% for record in page_obj.object_list %} - - + - - - + - {% endfor %} @@ -53,6 +100,8 @@
{% trans "No pending triage actions" %}
-
{% trans "All packages are within acceptable risk parameters." %}
+
+ {% trans "All packages are within acceptable risk parameters." %} +
{% endif %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index cbe6b4eb..9ef6c681 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -152,7 +152,6 @@ from vulnerabilities.models import get_risk_level from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRecord -from vulnerabilities.triage.rules import RULE_REGISTRY as TRIAGE_RULE_REGISTRY class BaseProductViewMixin: @@ -1375,17 +1374,47 @@ def get_context_data(self, **kwargs): product = self.object action_labels = dict(TriageAction.choices) - rule_labels = { - rule_type: handler.label for rule_type, handler in TRIAGE_RULE_REGISTRY.items() + action_styles = { + "upgrade": ("bg-danger-subtle text-danger-emphasis", "fa-arrow-circle-up"), + "apply_patch": ("bg-danger-subtle text-danger-emphasis", "fa-wrench"), + "replace_package": ("bg-warning-subtle text-warning-emphasis", "fa-exchange-alt"), + "forensic_analysis": ("bg-warning-subtle text-warning-emphasis", "fa-search"), + "reachability_analysis": ("bg-warning-subtle text-warning-emphasis", "fa-sitemap"), + "change_config": ("bg-info-subtle text-info-emphasis", "fa-cog"), + "notify": ("bg-primary-subtle text-primary-emphasis", "fa-bell"), + "create_request": ("bg-secondary-subtle text-secondary-emphasis", "fa-file-alt"), } + default_style = ("bg-secondary-subtle text-secondary-emphasis", "fa-exclamation-circle") triage_qs = ( TriageRecord.objects.filter(product_package__product=product) .primary_actions() .select_related( "product_package__package", + "product_package", "ruleset", ) + .annotate( + vulnerability_count=Count( + "product_package__package__affected_by_vulnerabilities", + distinct=True, + ), + critical_count=Count( + "product_package__package__affected_by_vulnerabilities", + filter=Q( + product_package__package__affected_by_vulnerabilities__risk_level="critical" + ), + distinct=True, + ), + high_count=Count( + "product_package__package__affected_by_vulnerabilities", + filter=Q( + product_package__package__affected_by_vulnerabilities__risk_level="high" + ), + distinct=True, + ), + ) + .order_by("-product_package__weighted_risk_score") ) total_count = triage_qs.count() @@ -1395,9 +1424,9 @@ def get_context_data(self, **kwargs): for record in page_obj.object_list: record.action_label = action_labels.get(record.action, record.action) - record.rule_labels = [ - rule_labels.get(rule_type, rule_type) for rule_type in record.matched_rules - ] + badge_class, icon = action_styles.get(record.action, default_style) + record.action_badge_class = badge_class + record.action_icon = icon context_data = super().get_context_data(**kwargs) context_data.update( From 81f506b52c5ca51ea58120bed3c13446b4b767c1 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 10 Aug 2026 12:33:15 +0400 Subject: [PATCH 33/90] refine tab header Signed-off-by: tdruez --- .../product_portfolio/tabs/tab_triage.html | 16 +++------------- product_portfolio/views.py | 14 ++++++++++---- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_triage.html b/product_portfolio/templates/product_portfolio/tabs/tab_triage.html index a63af764..e42e8aa1 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_triage.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_triage.html @@ -9,8 +9,7 @@ {% endif %} -{% if page_obj.object_list %} -
{% trans "Package" %}{% trans "Recommended action" %}{% trans "Ruleset" %}{% trans "Rules triggered" %}{% trans "Detected" %}{% trans "Package" %}{% trans "Recommended action" %}{% trans "Vulnerability exposure" %}{% trans "Detected" %}
- - - {{ record.product_package.package }} - - +
+ + {{ record.product_package.package }} + +
+ {% with score=record.product_package.weighted_risk_score %} + {% if score %} + + {% trans "Risk" %} {{ score }} + + {% endif %} + {% endwith %} + {% if record.product_package.is_deployed %} + + {% trans "Deployed" %} + + {% endif %} + {% if record.product_package.purpose %} + + {{ record.product_package.purpose.label }} + + {% endif %} +
- - {{ record.action_label }} - + +
+ + {{ record.action_label }} + +
+
+ {{ record.ruleset.name }} +
{{ record.ruleset.name }} - {% for label in record.rule_labels %} -
{{ label }}
- {% endfor %} +
+
+ {% if record.critical_count %} + + {{ record.critical_count }} {% trans "critical" %} + + {% endif %} + {% if record.high_count %} + + {{ record.high_count }} {% trans "high" %} + + {% endif %} + {% if record.vulnerability_count and not record.critical_count and not record.high_count %} + + {{ record.vulnerability_count }} {% trans "vulnerabilities" %} + + {% endif %} +
+ {% if record.vulnerability_count %} + + {% endif %} +
+ {{ record.detected_date|date:"N j, Y" }} {{ record.detected_date|date:"Y-m-d" }}
+
@@ -93,15 +92,6 @@ {% endfor %}
{% trans "Package" %}
- {% if page_obj.paginator.num_pages > 1 %} - {% include 'pagination/object_list_pagination.html' with hx_target=tab_id_html %} - {% endif %} -{% else %} -
- -
{% trans "No pending triage actions" %}
-
- {% trans "All packages are within acceptable risk parameters." %} -
-
+{% if page_obj.paginator.num_pages > 1 %} + {% include 'pagination/object_list_pagination.html' with hx_target=tab_id_html %} {% endif %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 9ef6c681..5e38d945 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -676,11 +676,17 @@ def tab_triage(self): TriageRecord.objects.filter(product_package__product=product).primary_actions().count() ) - badge_class = "bg-primary-subtle text-primary-emphasis" - if triage_count > 0: - badge_class = "bg-warning-subtle text-warning-emphasis" + if triage_count == 0: + label = 'Triage 0' + return { + "label": mark_safe(label), + "fields": [], + "disabled": True, + "tooltip": "No pending triage actions for this product", + } - label = f'Triage {triage_count}' + badge = f'{triage_count}' + label = f"Triage {badge}" tab_view_url = product.get_url("tab_triage") if full_query_string := self.request.META["QUERY_STRING"]: From 427d96545929ae9bc20cb1c2af3dc12a890e0b99 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 10 Aug 2026 12:39:33 +0400 Subject: [PATCH 34/90] refine default ordering Signed-off-by: tdruez --- .../product_portfolio/tabs/tab_triage.html | 49 +++++++++---------- product_portfolio/views.py | 2 +- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_triage.html b/product_portfolio/templates/product_portfolio/tabs/tab_triage.html index e42e8aa1..94923dc6 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_triage.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_triage.html @@ -9,8 +9,8 @@ {% endif %} - - +
+ @@ -20,14 +20,15 @@ {% for record in page_obj.object_list %} - - + - - - diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 5e38d945..4d496efe 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -1420,7 +1420,7 @@ def get_context_data(self, **kwargs): distinct=True, ), ) - .order_by("-product_package__weighted_risk_score") + .order_by(F("product_package__weighted_risk_score").desc(nulls_last=True)) ) total_count = triage_qs.count() From 0f7ae37b810282ada56157cd2de82ae5ba910ccb Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 10 Aug 2026 14:27:30 +0400 Subject: [PATCH 35/90] add sort and filtering Signed-off-by: tdruez --- product_portfolio/filters.py | 31 +++++++++++++++++++ .../product_portfolio/tabs/tab_triage.html | 21 ++----------- product_portfolio/views.py | 29 +++++++++++++++-- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index 06134052..ed238817 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -43,6 +43,8 @@ from vulnerabilities.models import RISK_SCORE_RANGES from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysisMixin +from vulnerabilities.triage.models import TriageAction +from vulnerabilities.triage.models import TriageRecord class HasComplianceIssueFilter(django_filters.BooleanFilter): @@ -584,3 +586,32 @@ class Meta: "is_pinned", "is_direct", ] + + +class TriageRecordFilterSet(DataspacedFilterSet): + dropdown_fields = ["action"] + + q = SearchFilter( + label=_("Search"), + search_fields=[ + "product_package__package__name", + "product_package__package__namespace", + "product_package__package__version", + ], + ) + action = django_filters.ChoiceFilter( + label=_("Action"), + choices=TriageAction.choices, + empty_label=_("All actions"), + ) + sort = DefaultOrderingFilter( + label=_("Sort"), + fields=[ + ("product_package__package__name", "package"), + ("detected_date", "detected_date"), + ], + ) + + class Meta: + model = TriageRecord + fields = ["action"] diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_triage.html b/product_portfolio/templates/product_portfolio/tabs/tab_triage.html index 94923dc6..5850f3bf 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_triage.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_triage.html @@ -1,23 +1,9 @@ {% load i18n %} -
-
- {{ total_count }} package{{ total_count|pluralize }} with pending triage action{{ total_count|pluralize }} -
- {% if page_obj.paginator.num_pages > 1 %} - {% include 'pagination/object_list_pagination.html' with hx_target=tab_id_html %} - {% endif %} -
+{% include 'tabs/pagination.html' with search_verbose_name="packages" %}
{% trans "Package" %} {% trans "Recommended action" %}
- - {{ record.product_package.package }} - -
+
+ + + {{ record.product_package.package }} + + +
{% with score=record.product_package.weighted_risk_score %} {% if score %} @@ -36,29 +37,23 @@ {% endif %} {% endwith %} {% if record.product_package.is_deployed %} - - {% trans "Deployed" %} - + {% trans "Deployed" %} {% endif %} {% if record.product_package.purpose %} - - {{ record.product_package.purpose.label }} - + {{ record.product_package.purpose.label }} {% endif %}
-
- - {{ record.action_label }} - -
-
+
+ + {{ record.action_label }} + +
{{ record.ruleset.name }}
-
+
+
{% if record.critical_count %} {{ record.critical_count }} {% trans "critical" %} @@ -76,7 +71,7 @@ {% endif %}
{% if record.vulnerability_count %} -
+ {{ record.detected_date|date:"N j, Y" }}
- - - - - - - - + {% include 'includes/object_list_table_header.html' %} {% for record in page_obj.object_list %} @@ -87,6 +73,3 @@ {% endfor %}
{% trans "Package" %}{% trans "Recommended action" %}{% trans "Vulnerability exposure" %}{% trans "Detected" %}
-{% if page_obj.paginator.num_pages > 1 %} - {% include 'pagination/object_list_pagination.html' with hx_target=tab_id_html %} -{% endif %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 4d496efe..02b58da6 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -119,6 +119,7 @@ from product_portfolio.filters import ProductComponentFilterSet from product_portfolio.filters import ProductFilterSet from product_portfolio.filters import ProductPackageFilterSet +from product_portfolio.filters import TriageRecordFilterSet from product_portfolio.forms import AttributionConfigurationForm from product_portfolio.forms import BaseProductRelationshipInlineFormSet from product_portfolio.forms import ComparisonExcludeFieldsForm @@ -1369,12 +1370,25 @@ class ProductTabTriageView( LoginRequiredMixin, BaseProductViewMixin, PaginationMixin, + TableHeaderMixin, TabContentView, ): template_name = "product_portfolio/tabs/tab_triage.html" paginate_by = 50 query_dict_page_param = "triage-page" tab_id = "triage" + table_model = TriageRecord + filterset_class = TriageRecordFilterSet + table_headers = ( + Header("package", _("Package"), help_text=_("Package with a pending triage action")), + Header("action", _("Recommended action"), filter="action"), + Header( + "vulnerability_exposure", + _("Vulnerability exposure"), + help_text=_("Known vulnerabilities affecting this package"), + ), + Header("detected_date", _("Detected")), + ) def get_context_data(self, **kwargs): product = self.object @@ -1424,7 +1438,17 @@ def get_context_data(self, **kwargs): ) total_count = triage_qs.count() - paginator = Paginator(triage_qs, self.paginate_by) + self.filterset = self.filterset_class( + self.request.GET, + queryset=triage_qs, + dataspace=product.dataspace, + prefix=self.tab_id, + anchor=f"#{self.tab_id}", + ) + + context_data = super().get_context_data(**kwargs) + + paginator = Paginator(self.filterset.qs, self.paginate_by) page_number = self.request.GET.get(self.query_dict_page_param) page_obj = paginator.get_page(page_number) @@ -1434,11 +1458,12 @@ def get_context_data(self, **kwargs): record.action_badge_class = badge_class record.action_icon = icon - context_data = super().get_context_data(**kwargs) context_data.update( { + "filterset": self.filterset, "page_obj": page_obj, "total_count": total_count, + "search_query": self.request.GET.get(f"{self.tab_id}-q", ""), } ) From cafae070c93a1d5731dcd33d58abc552531a8f99 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 10 Aug 2026 18:42:29 +0400 Subject: [PATCH 36/90] move triage content into the vulnerabilites tab Signed-off-by: tdruez --- dejacode/static/css/dejacode_bootstrap.css | 4 +- product_portfolio/filters.py | 46 ++-- .../tabs/tab_packages_vulnerabilities.html | 58 +++-- .../product_portfolio/tabs/tab_triage.html | 75 ------- product_portfolio/urls.py | 2 - product_portfolio/views.py | 198 ++++-------------- vulnerabilities/triage/models.py | 2 +- 7 files changed, 105 insertions(+), 280 deletions(-) delete mode 100644 product_portfolio/templates/product_portfolio/tabs/tab_triage.html diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index 4f0b48fb..047587cb 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -433,13 +433,13 @@ table.vulnerabilities-table .column-summary { width: 300px; } #tab_vulnerabilities .column-vulnerability_analyses__state { - min-width: 125px; + min-width: 100px; } #tab_vulnerabilities .column-vulnerability_analyses__justification { min-width: 130px; } #tab_vulnerabilities .column-vulnerability_analyses__responses { - width: 185px; + min-width: 120px; } #tab_vulnerabilities .column-vulnerability_analyses__is_reachable { width: 80px; diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index ed238817..04d91cfc 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -44,7 +44,6 @@ from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysisMixin from vulnerabilities.triage.models import TriageAction -from vulnerabilities.triage.models import TriageRecord class HasComplianceIssueFilter(django_filters.BooleanFilter): @@ -368,6 +367,7 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet): dropdown_fields = [ "is_modified", "weighted_risk_score", + "triage_action", "vulnerability_analyses__state", "vulnerability_analyses__justification", "responses", @@ -421,6 +421,12 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet): ("unknown", _("Reachability not known")), ), ) + triage_action = django_filters.ChoiceFilter( + label=_("Triage action"), + choices=TriageAction.choices, + empty_label=_("All actions"), + method="filter_triage_action", + ) compliance_issues = HasComplianceIssueFilter( field_name="package__usage_policy__compliance_alert", distinct=True, @@ -440,6 +446,15 @@ class Meta: "exploitability", ] + @staticmethod + def filter_triage_action(queryset, name, value): + if not value: + return queryset + return queryset.filter( + triage_records__action=value, + triage_records__ruleset__enabled=True, + ).distinct() + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.filters["vulnerability_analyses__state"].extra["null_label"] = "(No values)" @@ -586,32 +601,3 @@ class Meta: "is_pinned", "is_direct", ] - - -class TriageRecordFilterSet(DataspacedFilterSet): - dropdown_fields = ["action"] - - q = SearchFilter( - label=_("Search"), - search_fields=[ - "product_package__package__name", - "product_package__package__namespace", - "product_package__package__version", - ], - ) - action = django_filters.ChoiceFilter( - label=_("Action"), - choices=TriageAction.choices, - empty_label=_("All actions"), - ) - sort = DefaultOrderingFilter( - label=_("Sort"), - fields=[ - ("product_package__package__name", "package"), - ("detected_date", "detected_date"), - ], - ) - - class Meta: - model = TriageRecord - fields = ["action"] diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index 43ed66a3..95f890f3 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -19,24 +19,36 @@ {{ product_package.package }} - {% if product_package.purpose %} -
- {{ product_package.purpose.label_with_icon }} - {% if product_package.purpose.exposure_factor %} -
- - Exposure factor: {{ product_package.purpose.exposure_factor }} -
+
+ {% with score=product_package.weighted_risk_score %} + {% if score %} + + {% trans "Risk" %} {{ score }} + {% endif %} -
- {{ product_package.is_deployed|as_icon }} - {% if product_package.is_deployed %}Deployed{% else %}Not deployed{% endif %} -
-
- {% endif %} + {% endwith %} + {% if product_package.is_deployed %} + {% trans "Deployed" %} + {% endif %} + {% if product_package.purpose %} + {{ product_package.purpose.label }} + {% endif %} +
- {% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=product_package.weighted_risk_score only %} + {% if product_package.triage_record %} + {% with record=product_package.triage_record %} + + {{ record.action_label }} + +
+ {{ record.ruleset.name }} +
+
+ {% trans "Since:" %} {{ record.detected_date|date:"M j, Y" }} +
+ {% endwith %} + {% endif %} {% for vulnerability in product_package.package.affected_by_vulnerabilities.all %} {% if not forloop.first %}{% endif %} @@ -56,9 +68,19 @@ {% endif %} -
- {% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=vulnerability.risk_score label="Risk:" only %} - {% include 'vulnerabilities/includes/exploitability.html' with instance=vulnerability only %} +
+ {% with score=vulnerability.risk_score %} + {% if score %} + + {% trans "Risk:" %} {{ score }} + + {% endif %} + {% endwith %} + {% if vulnerability.exploitability %} + + {{ vulnerability.get_exploitability_display }} + + {% endif %}
{% if vulnerability.aliases %}
diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_triage.html b/product_portfolio/templates/product_portfolio/tabs/tab_triage.html deleted file mode 100644 index 5850f3bf..00000000 --- a/product_portfolio/templates/product_portfolio/tabs/tab_triage.html +++ /dev/null @@ -1,75 +0,0 @@ -{% load i18n %} - -{% include 'tabs/pagination.html' with search_verbose_name="packages" %} - - - {% include 'includes/object_list_table_header.html' %} - - {% for record in page_obj.object_list %} - - - - - - - {% endfor %} - -
- - - {{ record.product_package.package }} - - -
- {% with score=record.product_package.weighted_risk_score %} - {% if score %} - - {% trans "Risk" %} {{ score }} - - {% endif %} - {% endwith %} - {% if record.product_package.is_deployed %} - {% trans "Deployed" %} - {% endif %} - {% if record.product_package.purpose %} - {{ record.product_package.purpose.label }} - {% endif %} -
-
- - {{ record.action_label }} - -
- {{ record.ruleset.name }} -
-
-
- {% if record.critical_count %} - - {{ record.critical_count }} {% trans "critical" %} - - {% endif %} - {% if record.high_count %} - - {{ record.high_count }} {% trans "high" %} - - {% endif %} - {% if record.vulnerability_count and not record.critical_count and not record.high_count %} - - {{ record.vulnerability_count }} {% trans "vulnerabilities" %} - - {% endif %} -
- {% if record.vulnerability_count %} - - {% endif %} -
- {{ record.detected_date|date:"N j, Y" }} -
diff --git a/product_portfolio/urls.py b/product_portfolio/urls.py index a87af947..8231594a 100644 --- a/product_portfolio/urls.py +++ b/product_portfolio/urls.py @@ -34,7 +34,6 @@ from product_portfolio.views import ProductTabDependenciesView from product_portfolio.views import ProductTabInventoryView from product_portfolio.views import ProductTabLicensesView -from product_portfolio.views import ProductTabTriageView from product_portfolio.views import ProductTabVulnerabilitiesView from product_portfolio.views import ProductTreeComparisonView from product_portfolio.views import ProductUpdateView @@ -153,7 +152,6 @@ def product_path(path_segment, view): *product_path("tab_dependencies", ProductTabDependenciesView.as_view()), *product_path("tab_licenses", ProductTabLicensesView.as_view()), *product_path("tab_vulnerabilities", ProductTabVulnerabilitiesView.as_view()), - *product_path("tab_triage", ProductTabTriageView.as_view()), *product_path("tab_activity", ProductTabActivityView.as_view()), *product_path("tab_inventory", ProductTabInventoryView.as_view()), *product_path("tab_compliance", ProductTabComplianceView.as_view()), diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 02b58da6..b5c5b5b0 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -119,7 +119,6 @@ from product_portfolio.filters import ProductComponentFilterSet from product_portfolio.filters import ProductFilterSet from product_portfolio.filters import ProductPackageFilterSet -from product_portfolio.filters import TriageRecordFilterSet from product_portfolio.forms import AttributionConfigurationForm from product_portfolio.forms import BaseProductRelationshipInlineFormSet from product_portfolio.forms import ComparisonExcludeFieldsForm @@ -154,6 +153,21 @@ from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRecord +TRIAGE_ACTION_STYLES = { + "upgrade": ("bg-danger-subtle text-danger-emphasis", "fa-arrow-circle-up"), + "apply_patch": ("bg-danger-subtle text-danger-emphasis", "fa-wrench"), + "replace_package": ("bg-warning-subtle text-warning-emphasis", "fa-exchange-alt"), + "forensic_analysis": ("bg-warning-subtle text-warning-emphasis", "fa-search"), + "reachability_analysis": ("bg-warning-subtle text-warning-emphasis", "fa-sitemap"), + "change_config": ("bg-info-subtle text-info-emphasis", "fa-cog"), + "notify": ("bg-primary-subtle text-primary-emphasis", "fa-bell"), + "create_request": ("bg-secondary-subtle text-secondary-emphasis", "fa-file-alt"), +} +TRIAGE_ACTION_DEFAULT_STYLE = ( + "bg-secondary-subtle text-secondary-emphasis", + "fa-exclamation-circle", +) + class BaseProductViewMixin: model = Product @@ -298,7 +312,6 @@ class ProductDetailsView( ], }, "vulnerabilities": {}, - "triage": {}, "codebase": { "fields": [ "path", @@ -670,40 +683,6 @@ def tab_vulnerabilities(self): "fields": [(None, tab_context, None, template)], } - def tab_triage(self): - product = self.object - - triage_count = ( - TriageRecord.objects.filter(product_package__product=product).primary_actions().count() - ) - - if triage_count == 0: - label = 'Triage 0' - return { - "label": mark_safe(label), - "fields": [], - "disabled": True, - "tooltip": "No pending triage actions for this product", - } - - badge = f'{triage_count}' - label = f"Triage {badge}" - - tab_view_url = product.get_url("tab_triage") - if full_query_string := self.request.META["QUERY_STRING"]: - tab_view_url += f"?{full_query_string}" - - template = "tabs/tab_async_loader.html" - tab_context = { - "tab_view_url": tab_view_url, - "tab_object_name": "triage actions", - } - - return { - "label": mark_safe(label), - "fields": [(None, tab_context, None, template)], - } - def tab_codebase(self): codebaseresources_count = self.object.codebaseresources.count() if not codebaseresources_count: @@ -1249,7 +1228,12 @@ class ProductTabVulnerabilitiesView( filterset_class = ProductPackageFilterSet table_headers = ( Header("affected_packages", _("Package"), help_text="Affected product packages"), - Header("weighted_risk_score", _("Risk"), filter="weighted_risk_score"), + Header( + "triage_action", + _("Recommended action"), + help_text=_("Action recommended by the triage engine for this package"), + filter="triage_action", + ), Header( "advisory_uid", _("Vulnerabilities"), @@ -1298,7 +1282,7 @@ def get_context_data(self, **kwargs): base_productpackage_qs = product.get_vulnerable_productpackages(risk_threshold) vulnerability_qs = Vulnerability.objects.prefetch_related( "vulnerability_analyses" - ).order_by("-risk_score") + ).order_by(F("risk_score").desc(nulls_last=True)) package_qs = ( Package.objects.all() .only_rendering_fields() @@ -1316,7 +1300,7 @@ def get_context_data(self, **kwargs): Prefetch("package", package_qs), ) .order_by( - "-weighted_risk_score", + F("weighted_risk_score").desc(nulls_last=True), "package__name", ) ) @@ -1344,126 +1328,36 @@ def get_context_data(self, **kwargs): vulnerability.vulnerability_analysis = analysis continue - context_data.update( - { - "filterset": self.filterset, - "page_obj": page_obj, - "total_count": base_productpackage_qs.count(), - "search_query": self.request.GET.get("vulnerabilities-q", ""), - "risk_threshold": risk_threshold, - } - ) - - if page_obj: - previous_url, next_url = self.get_previous_next(page_obj) - context_data.update( - { - "previous_url": (previous_url or "") + f"#{self.tab_id}", - "next_url": (next_url or "") + f"#{self.tab_id}", - } - ) - - return context_data - - -class ProductTabTriageView( - LoginRequiredMixin, - BaseProductViewMixin, - PaginationMixin, - TableHeaderMixin, - TabContentView, -): - template_name = "product_portfolio/tabs/tab_triage.html" - paginate_by = 50 - query_dict_page_param = "triage-page" - tab_id = "triage" - table_model = TriageRecord - filterset_class = TriageRecordFilterSet - table_headers = ( - Header("package", _("Package"), help_text=_("Package with a pending triage action")), - Header("action", _("Recommended action"), filter="action"), - Header( - "vulnerability_exposure", - _("Vulnerability exposure"), - help_text=_("Known vulnerabilities affecting this package"), - ), - Header("detected_date", _("Detected")), - ) - - def get_context_data(self, **kwargs): - product = self.object - + # Attach the winning triage record to each product_package action_labels = dict(TriageAction.choices) - action_styles = { - "upgrade": ("bg-danger-subtle text-danger-emphasis", "fa-arrow-circle-up"), - "apply_patch": ("bg-danger-subtle text-danger-emphasis", "fa-wrench"), - "replace_package": ("bg-warning-subtle text-warning-emphasis", "fa-exchange-alt"), - "forensic_analysis": ("bg-warning-subtle text-warning-emphasis", "fa-search"), - "reachability_analysis": ("bg-warning-subtle text-warning-emphasis", "fa-sitemap"), - "change_config": ("bg-info-subtle text-info-emphasis", "fa-cog"), - "notify": ("bg-primary-subtle text-primary-emphasis", "fa-bell"), - "create_request": ("bg-secondary-subtle text-secondary-emphasis", "fa-file-alt"), - } - default_style = ("bg-secondary-subtle text-secondary-emphasis", "fa-exclamation-circle") - - triage_qs = ( - TriageRecord.objects.filter(product_package__product=product) - .primary_actions() - .select_related( - "product_package__package", - "product_package", - "ruleset", - ) - .annotate( - vulnerability_count=Count( - "product_package__package__affected_by_vulnerabilities", - distinct=True, - ), - critical_count=Count( - "product_package__package__affected_by_vulnerabilities", - filter=Q( - product_package__package__affected_by_vulnerabilities__risk_level="critical" - ), - distinct=True, - ), - high_count=Count( - "product_package__package__affected_by_vulnerabilities", - filter=Q( - product_package__package__affected_by_vulnerabilities__risk_level="high" - ), - distinct=True, - ), + triage_by_package = { + record.product_package_id: record + for record in TriageRecord.objects.filter( + product_package__in=page_obj.object_list, ) - .order_by(F("product_package__weighted_risk_score").desc(nulls_last=True)) - ) - total_count = triage_qs.count() - - self.filterset = self.filterset_class( - self.request.GET, - queryset=triage_qs, - dataspace=product.dataspace, - prefix=self.tab_id, - anchor=f"#{self.tab_id}", - ) - - context_data = super().get_context_data(**kwargs) - - paginator = Paginator(self.filterset.qs, self.paginate_by) - page_number = self.request.GET.get(self.query_dict_page_param) - page_obj = paginator.get_page(page_number) - - for record in page_obj.object_list: - record.action_label = action_labels.get(record.action, record.action) - badge_class, icon = action_styles.get(record.action, default_style) - record.action_badge_class = badge_class - record.action_icon = icon + .primary_actions() + .select_related("ruleset") + } + for product_package in page_obj.object_list: + triage_record = triage_by_package.get(product_package.id) + if triage_record: + triage_record.action_label = action_labels.get( + triage_record.action, triage_record.action + ) + badge_class, icon = TRIAGE_ACTION_STYLES.get( + triage_record.action, TRIAGE_ACTION_DEFAULT_STYLE + ) + triage_record.action_badge_class = badge_class + triage_record.action_icon = icon + product_package.triage_record = triage_record context_data.update( { "filterset": self.filterset, "page_obj": page_obj, - "total_count": total_count, - "search_query": self.request.GET.get(f"{self.tab_id}-q", ""), + "total_count": base_productpackage_qs.count(), + "search_query": self.request.GET.get("vulnerabilities-q", ""), + "risk_threshold": risk_threshold, } ) diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index 4af753fa..191d20ea 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -93,7 +93,7 @@ def primary_actions(self): product_package=OuterRef("product_package"), ruleset__enabled=True, ) - .order_by("-ruleset__precedence") + .order_by("-ruleset__precedence", "ruleset__name") .values("ruleset_id")[:1] ) return self.filter( From a565d8261303b542f940c1b08e748aeb2df8d785 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 11 Aug 2026 08:37:54 +0400 Subject: [PATCH 37/90] remove TriageRecordAdmin Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 85 --------------------------------- 1 file changed, 85 deletions(-) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index fd25f668..a0a9292c 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -14,7 +14,6 @@ from dje.admin import dejacode_site from vulnerabilities.triage.forms import TriageRulesetForm from vulnerabilities.triage.models import TriageAction -from vulnerabilities.triage.models import TriageRecord from vulnerabilities.triage.models import TriageRuleset from vulnerabilities.triage.rules import RULE_REGISTRY @@ -103,87 +102,3 @@ def get_fieldsets(self, request, obj=None): return base_fieldsets + rule_fieldsets -@admin.register(TriageRecord, site=dejacode_site) -class TriageRecordAdmin(DataspacedAdmin): - short_description = ( - "A Package Triage record stores the recommended action for a specific package" - " usage within a product, as determined by the evaluation engine." - ) - long_description = ( - "Package Triage records are created and updated automatically when a ruleset is" - " evaluated against a product. Each record links a specific package usage to the" - " ruleset that triggered it, captures which rules fired, and timestamps the first" - " detection and most recent check. These records are read-only in the admin." - ) - - list_display = [ - "get_product", - "get_package", - "ruleset", - "get_action_label", - "get_matched_rules", - "detected_date", - "last_checked", - "get_dataspace", - ] - list_filter = DataspacedAdmin.list_filter + ("action", "ruleset") - search_fields = [ - "product_package__package__name", - "product_package__product__name", - "ruleset__name", - ] - readonly_fields = DataspacedAdmin.readonly_fields + ( - "product_package", - "ruleset", - "get_action_label", - "matched_rules", - "detected_date", - "last_checked", - ) - ordering = [ - "product_package__product__name", - "product_package__product__version", - "product_package__package__name", - "-ruleset__precedence", - ] - - @admin.display(description="Product", ordering="product_package__product__name") - def get_product(self, obj): - return obj.product_package.product - - @admin.display(description="Package", ordering="product_package__package__name") - def get_package(self, obj): - return obj.product_package.package - - @admin.display(description="Action") - def get_action_label(self, obj): - return dict(TriageAction.choices).get(obj.action, obj.action) - - @admin.display(description="Matched rules") - def get_matched_rules(self, obj): - labels = [ - RULE_REGISTRY[rule_type].label - for rule_type in obj.matched_rules - if rule_type in RULE_REGISTRY - ] - if not labels: - return "" - return mark_safe("
".join(escape(label) for label in labels)) - - def get_queryset(self, request): - return ( - super() - .get_queryset(request) - .product_secured(request.user, "view_product") - .select_related( - "product_package__product", - "product_package__package", - "ruleset", - ) - ) - - def has_add_permission(self, request): - return False - - def has_change_permission(self, request, obj=None): - return False From 72b33d9de094ffd0331232735718789819f77066 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 11 Aug 2026 08:41:31 +0400 Subject: [PATCH 38/90] add ProductTriageRuleset relation Signed-off-by: tdruez --- .../management/commands/evaluate_triage.py | 35 +++++++++---------- .../migrations/0007_producttriageruleset.py | 33 +++++++++++++++++ vulnerabilities/triage/models.py | 34 ++++++++++++++++-- vulnerabilities/triage/signals.py | 12 +++++++ 4 files changed, 92 insertions(+), 22 deletions(-) create mode 100644 vulnerabilities/triage/migrations/0007_producttriageruleset.py diff --git a/vulnerabilities/triage/management/commands/evaluate_triage.py b/vulnerabilities/triage/management/commands/evaluate_triage.py index cd766fe4..a01521e0 100644 --- a/vulnerabilities/triage/management/commands/evaluate_triage.py +++ b/vulnerabilities/triage/management/commands/evaluate_triage.py @@ -6,14 +6,13 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # -from django.apps import apps from django.core.management.base import BaseCommand from django.core.management.base import CommandError from dje.models import Dataspace from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.models import ProductTriageRuleset from vulnerabilities.triage.models import TriageRecord -from vulnerabilities.triage.models import TriageRuleset """ docker compose -f compose.dev.yml exec web ./manage.py evaluate_triage nexB @@ -34,27 +33,25 @@ def handle(self, *args, **options): except Dataspace.DoesNotExist: raise CommandError(f'Dataspace "{dataspace_name}" does not exist.') - Product = apps.get_model("product_portfolio", "product") - products = Product.unsecured_objects.scope(dataspace) - rulesets = TriageRuleset.objects.filter(dataspace=dataspace, enabled=True) - - product_count = products.count() - ruleset_count = rulesets.count() - - self.stdout.write(f"Products: {product_count}, rulesets: {ruleset_count}") + assignments = ( + ProductTriageRuleset.objects.filter( + dataspace=dataspace, + ruleset__enabled=True, + ) + .select_related("product", "ruleset") + .order_by("product__name", "product__version", "-ruleset__precedence") + ) - if not ruleset_count: - self.stdout.write("No enabled rulesets found.") - return + assignment_count = assignments.count() + self.stdout.write(f"Active assignments: {assignment_count}") - if not product_count: - self.stdout.write("No products found.") + if not assignment_count: + self.stdout.write("No active ruleset assignments found.") return - for product in products: - self.stdout.write(f" {product}") - for ruleset in rulesets: - evaluate_ruleset(ruleset=ruleset, product=product) + for assignment in assignments: + self.stdout.write(f" {assignment.product} / {assignment.ruleset}") + evaluate_ruleset(ruleset=assignment.ruleset, product=assignment.product) total = TriageRecord.objects.filter(dataspace=dataspace).count() self.stdout.write( diff --git a/vulnerabilities/triage/migrations/0007_producttriageruleset.py b/vulnerabilities/triage/migrations/0007_producttriageruleset.py new file mode 100644 index 00000000..331bdf5e --- /dev/null +++ b/vulnerabilities/triage/migrations/0007_producttriageruleset.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.6 on 2026-08-11 04:40 + +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', '0019_productaffectedbyvulnerability_detected_date'), + ('vulnerabilities_triage', '0006_rename_productpackagetriage_triagerecord'), + ] + + operations = [ + migrations.CreateModel( + name='ProductTriageRuleset', + 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')), + ('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 this ruleset is activated for.', on_delete=django.db.models.deletion.CASCADE, related_name='product_triage_rulesets', to='product_portfolio.product')), + ('ruleset', models.ForeignKey(help_text='The ruleset to evaluate against this product.', on_delete=django.db.models.deletion.CASCADE, related_name='product_triage_rulesets', to='vulnerabilities_triage.triageruleset')), + ], + options={ + 'ordering': ['-ruleset__precedence', 'ruleset__name'], + 'unique_together': {('dataspace', 'uuid'), ('product', 'ruleset')}, + }, + bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), + ), + ] diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index 191d20ea..e4b311b8 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -83,15 +83,19 @@ def product_secured(self, user=None, perms="view_product"): def primary_actions(self): """ - Return one record per product_package: the highest-precedence active ruleset. + Return one record per product_package: the highest-precedence active ruleset + that is explicitly assigned to the package's product via ProductTriageRuleset. - Uses a correlated subquery to find the winning ruleset per package rather than - DISTINCT ON, which breaks under Django's COUNT wrapping and select_related JOINs. + Uses a correlated subquery rather than DISTINCT ON, which breaks under Django's + COUNT wrapping and select_related JOINs. """ winning_ruleset_id = ( self.model.objects.filter( product_package=OuterRef("product_package"), ruleset__enabled=True, + ruleset__product_triage_rulesets__product=OuterRef( + "product_package__product" + ), ) .order_by("-ruleset__precedence", "ruleset__name") .values("ruleset_id")[:1] @@ -102,6 +106,30 @@ def primary_actions(self): ) +class ProductTriageRuleset(DataspacedModel): + """Activates a TriageRuleset for evaluation against a specific Product.""" + + product = models.ForeignKey( + to="product_portfolio.Product", + on_delete=models.CASCADE, + related_name="product_triage_rulesets", + help_text=_("The product this ruleset is activated for."), + ) + ruleset = models.ForeignKey( + to="TriageRuleset", + on_delete=models.CASCADE, + related_name="product_triage_rulesets", + help_text=_("The ruleset to evaluate against this product."), + ) + + class Meta: + unique_together = [("product", "ruleset"), ("dataspace", "uuid")] + ordering = ["-ruleset__precedence", "ruleset__name"] + + def __str__(self): + return f"{self.product} / {self.ruleset}" + + class TriageRecord(DataspacedModel): """Stores the triage recommendation for a specific package usage within a product.""" diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index ed5d7960..edb5fccd 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -6,12 +6,24 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +from django.db.models.signals import post_delete from django.db.models.signals import post_save from django.dispatch import receiver +from vulnerabilities.triage.models import TriageRecord + @receiver(post_save, sender="vulnerabilities_triage.TriageRuleset") def delete_triage_records_on_disable(sender, instance, **kwargs): """Delete all package triage records when a ruleset is disabled.""" if not instance.enabled: instance.triage_records.all().delete() + + +@receiver(post_delete, sender="vulnerabilities_triage.ProductTriageRuleset") +def delete_triage_records_on_unassign(sender, instance, **kwargs): + """Delete triage records for a ruleset when it is de-assigned from a product.""" + TriageRecord.objects.filter( + ruleset=instance.ruleset, + product_package__product=instance.product, + ).delete() From b309fb32ab67aa4bb55c4136127b18f64532a8ac Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 11 Aug 2026 13:27:53 +0400 Subject: [PATCH 39/90] enable rulsets per products Signed-off-by: tdruez --- product_portfolio/forms.py | 2 + product_portfolio/models.py | 3 + .../modals/manage_triage_rulesets_form.html | 35 ++++++++++++ .../modals/manage_triage_rulesets_modal.html | 17 ++++++ .../product_portfolio/product_details.html | 48 +++++++++++++++- product_portfolio/tests/test_views.py | 23 ++++---- product_portfolio/urls.py | 2 + product_portfolio/views.py | 55 +++++++++++++++++++ vulnerabilities/triage/admin.py | 2 - vulnerabilities/triage/models.py | 4 +- 10 files changed, 172 insertions(+), 19 deletions(-) create mode 100644 product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html create mode 100644 product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_modal.html diff --git a/product_portfolio/forms.py b/product_portfolio/forms.py index 6c7482b3..57cf9eab 100644 --- a/product_portfolio/forms.py +++ b/product_portfolio/forms.py @@ -58,6 +58,7 @@ from product_portfolio.models import ScanCodeProject from product_portfolio.tasks import pull_project_data_from_scancodeio_task from product_portfolio.tasks import scancodeio_submit_project_task +from vulnerabilities.triage.models import ProductTriageRuleset class NameVersionValidationFormMixin: @@ -106,6 +107,7 @@ class ProductForm( ProductComponent, ProductPackage, CodebaseResource, + ProductTriageRuleset, ] keywords = KeywordsField() diff --git a/product_portfolio/models.py b/product_portfolio/models.py index b540f38b..a14b6ae9 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -495,6 +495,9 @@ def get_export_security_compliance_url(self): def get_evaluate_policy_rules_url(self): return self.get_url("evaluate_policy_rules") + def get_manage_triage_rulesets_url(self): + return self.get_url("manage_triage_rulesets") + @property def cyclonedx_bom_ref(self): return str(self.uuid) diff --git a/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html b/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html new file mode 100644 index 00000000..a5fce339 --- /dev/null +++ b/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html @@ -0,0 +1,35 @@ +{% load i18n %} +
+ {% if available_rulesets %} +

+ {% trans "Select the rules to activate for this product. Saving triggers an evaluation of all active packages." %} +

+
+ {% for ruleset in available_rulesets %} + + {% endfor %} +
+ {% else %} +

+ {% trans "No triage rules are available in this dataspace." %} +

+ {% endif %} +
diff --git a/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_modal.html b/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_modal.html new file mode 100644 index 00000000..e8bc1efb --- /dev/null +++ b/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_modal.html @@ -0,0 +1,17 @@ +{% load i18n %} + diff --git a/product_portfolio/templates/product_portfolio/product_details.html b/product_portfolio/templates/product_portfolio/product_details.html index cfe9dbeb..313c32b0 100644 --- a/product_portfolio/templates/product_portfolio/product_details.html +++ b/product_portfolio/templates/product_portfolio/product_details.html @@ -10,7 +10,7 @@ Manage - {% elif has_edit_productcomponent or has_edit_productpackage or has_change_codebaseresource_permission or has_add_productcomponent %} + {% elif has_edit_productcomponent or has_edit_productpackage or has_change_codebaseresource_permission or has_add_productcomponent or has_change_permission %} {% endif %} @@ -148,6 +158,9 @@ {% if request.user.dataspace.enable_vulnerablecodedb_access and product.vulnerability_count %} {% include 'product_portfolio/modals/vulnerability_analysis_modal.html' %} {% endif %} + {% if has_change_permission and request.user.dataspace.enable_vulnerablecodedb_access %} + {% include 'product_portfolio/modals/manage_triage_rulesets_modal.html' %} + {% endif %} {% endblock %} {% block messages-alert %} @@ -281,6 +294,39 @@ {% endif %} + {% if has_change_permission and request.user.dataspace.enable_vulnerablecodedb_access %} + + {% endif %} + {% if request.user.dataspace.enable_vulnerablecodedb_access and product.vulnerability_count %} diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index 2524a724..d7138663 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -75,11 +75,13 @@ {% endif %} - {% if vulnerability.triage_record and vulnerability.triage_record.action %} + {% if vulnerability.triage_record %} {% with record=vulnerability.triage_record %} - - {{ record.action_label }} - + {% if record.action %} + + {{ record.action_label }} + + {% endif %}
{{ record.ruleset.name }}
diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 06cb7db9..ec888641 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -1282,7 +1282,7 @@ def attach_vulnerability_analyses(self, page_obj): for analysis in vulnerability.vulnerability_analyses.all(): if analysis.product_package_id == product_package.id: vulnerability.vulnerability_analysis = analysis - continue + break REACHABILITY_FILTER_MAP = {"yes": True, "no": False, "unknown": None} @@ -1316,7 +1316,8 @@ def vulnerability_passes_display_filters(self, vulnerability, display_filters): is_reachable_filter = display_filters.get("is_reachable") if is_reachable_filter: expected = self.REACHABILITY_FILTER_MAP[is_reachable_filter] - if getattr(analysis, "is_reachable", object()) != expected: + actual = None if analysis is None else analysis.is_reachable + if actual != expected: return False return True @@ -2193,6 +2194,7 @@ def evaluate_policy_rules_view(request, dataspace, name, version=""): return HttpResponse(headers={"HX-Refresh": "true"}) +@require_http_methods(["GET", "POST"]) @login_required def manage_triage_rulesets_view(request, dataspace, name, version=""): guarded_qs = Product.objects.get_queryset(request.user, perms="change_product") @@ -2232,6 +2234,7 @@ def manage_triage_rulesets_view(request, dataspace, name, version=""): assigned_ruleset_ids = set(product.product_triage_rulesets.values_list("ruleset_id", flat=True)) action_labels = dict(TriageAction.choices) + for ruleset in available_rulesets: ruleset.action_label = action_labels.get(ruleset.action, ruleset.action) action_badge_class, action_icon = TRIAGE_ACTION_STYLES.get( diff --git a/vulnerabilities/triage/migrations/0001_initial.py b/vulnerabilities/triage/migrations/0001_initial.py index 4a8a7709..8e52ac5e 100644 --- a/vulnerabilities/triage/migrations/0001_initial.py +++ b/vulnerabilities/triage/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.6 on 2026-08-11 10:36 +# Generated by Django 6.0.6 on 2026-08-11 13:44 import django.db.models.deletion import dje.models @@ -32,7 +32,7 @@ class Migration(migrations.Migration): ], options={ 'ordering': ('-precedence', 'name'), - 'unique_together': {('dataspace', 'name'), ('dataspace', 'uuid')}, + 'unique_together': {('dataspace', 'name'), ('dataspace', 'precedence'), ('dataspace', 'uuid')}, }, bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), ), @@ -41,7 +41,7 @@ class Migration(migrations.Migration): 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')), - ('action', models.CharField(help_text='Recommended action captured at the time of evaluation.', max_length=50)), + ('action', models.CharField(blank=True, help_text='Recommended action captured at the time of evaluation.', max_length=50)), ('matched_rules', models.JSONField(default=list, help_text='Rules that fired for this vulnerability during evaluation.')), ('detected_date', models.DateTimeField(auto_now_add=True, help_text='Date and time when this recommendation was first generated.')), ('last_checked', models.DateTimeField(auto_now=True, help_text='Date and time of the last evaluation.')), @@ -66,7 +66,7 @@ class Migration(migrations.Migration): ('ruleset', models.ForeignKey(help_text='The ruleset to evaluate against this product.', on_delete=django.db.models.deletion.CASCADE, related_name='product_triage_rulesets', to='vulnerabilities_triage.triageruleset')), ], options={ - 'ordering': ['-ruleset__precedence', 'ruleset__name'], + 'ordering': ['-ruleset__precedence'], 'unique_together': {('dataspace', 'uuid'), ('product', 'ruleset')}, }, bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index 6a6ba2b4..af9ee84b 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -151,6 +151,7 @@ class TriageRecord(DataspacedModel): ) action = models.CharField( max_length=50, + blank=True, help_text=_("Recommended action captured at the time of evaluation."), ) matched_rules = models.JSONField( diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index 44fc8265..aa5e459a 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -167,6 +167,7 @@ def get_matching_vulnerabilities(self, product, parameters=None): PackageAffectedByVulnerability = apps.get_model( "component_catalog", "packageaffectedbyvulnerability" ) + ProductPackage = apps.get_model("product_portfolio", "productpackage") VulnerabilityAnalysis = apps.get_model("vulnerabilities", "vulnerabilityanalysis") parameters = parameters or {} min_risk_score = parameters.get( @@ -174,21 +175,27 @@ def get_matching_vulnerabilities(self, product, parameters=None): ) max_days = parameters.get("max_days", self.parameters_schema["max_days"]["default"]) cutoff_date = timezone.now() - timedelta(days=max_days) - terminal_vuln_ids = VulnerabilityAnalysis.objects.filter( - product_package__product=product, - state__in=TERMINAL_VULNERABILITY_STATES, - ).values_list("vulnerability_id", flat=True) stale_detection_vuln_ids = PackageAffectedByVulnerability.objects.filter( package__productpackages__product=product, detected_date__lte=cutoff_date, ).values_list("vulnerability_id", flat=True) + terminal_analysis = VulnerabilityAnalysis.objects.filter( + product_package=OuterRef("pk"), + vulnerability=OuterRef(OuterRef("pk")), + state__in=TERMINAL_VULNERABILITY_STATES, + ) + unresolved_package = ProductPackage.objects.filter( + product=product, + package__affected_by_vulnerabilities=OuterRef("pk"), + ).filter(~Exists(terminal_analysis)) + return ( Vulnerability.objects.filter( affected_packages__productpackages__product=product, risk_score__gte=min_risk_score, id__in=stale_detection_vuln_ids, ) - .exclude(id__in=terminal_vuln_ids) + .filter(Exists(unresolved_package)) .distinct() ) diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index f927edd4..8a34845c 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -15,7 +15,7 @@ @receiver(post_save, sender="vulnerabilities_triage.TriageRuleset") def delete_triage_records_on_disable(sender, instance, **kwargs): - """Delete all package triage records when a ruleset is disabled.""" + """Delete all triage records when a ruleset is disabled.""" if not instance.enabled: instance.triage_records.all().delete() From 9f9d22793a625f5cb3b44314d09b2d6672a60ae6 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 12 Aug 2026 08:29:30 +0400 Subject: [PATCH 46/90] rework the rules and rulesets Signed-off-by: tdruez --- .../commands/create_triage_rulesets.py | 56 +++++++++++-------- vulnerabilities/triage/rules.py | 8 ++- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index b6324eee..9a5b5c3e 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -20,9 +20,12 @@ REFERENCE_RULESETS = [ { "name": "Critical Exploited Vulnerability", - "description": "Packages with a critical vulnerability and a known active exploit.", + "description": ( + "Vulnerabilities with a critical risk score and a known active exploit" + " affecting the product." + ), "action": TriageAction.UPGRADE, - "precedence": 400, + "precedence": 700, "rules_config": { "risk_score": {"is_active": True, "min_risk_score": 8.0}, "exploited_vulnerability": {"is_active": True}, @@ -30,29 +33,33 @@ }, { "name": "Active Exploit", - "description": "Packages with a known exploit available, regardless of severity.", + "description": ( + "Vulnerabilities with a known active exploit affecting the product," + " regardless of severity." + ), "action": TriageAction.UPGRADE, - "precedence": 300, + "precedence": 600, "rules_config": { "exploited_vulnerability": {"is_active": True}, }, }, { "name": "Reachable Vulnerability", - "description": ( - "Packages with a vulnerability confirmed as reachable in the product context." - ), + "description": "Vulnerabilities confirmed as reachable within the product context.", "action": TriageAction.APPLY_PATCH, - "precedence": 250, + "precedence": 500, "rules_config": { "reachable_vulnerability": {"is_active": True}, }, }, { "name": "Critical Vulnerability", - "description": "Packages with a critical-severity vulnerability and no known exploit.", + "description": ( + "Vulnerabilities with a critical risk score and no known active exploit" + " affecting the product." + ), "action": TriageAction.APPLY_PATCH, - "precedence": 200, + "precedence": 400, "rules_config": { "risk_score": {"is_active": True, "min_risk_score": 8.0}, }, @@ -60,31 +67,34 @@ { "name": "Stale Vulnerability", "description": ( - "Packages with a critical-severity vulnerability left unaddressed" - " for more than 30 days." + "Vulnerabilities with a critical risk score left unaddressed" + " for more than 30 days in the product." ), "action": TriageAction.APPLY_PATCH, - "precedence": 150, + "precedence": 300, "rules_config": { "stale_vulnerability": {"is_active": True, "min_risk_score": 8.0, "max_days": 30}, }, }, { - "name": "Unresolved Vulnerability", - "description": "Packages with vulnerabilities that have no completed triage analysis.", - "action": TriageAction.FORENSIC_ANALYSIS, - "precedence": 100, + "name": "Dev-Only Vulnerable Package", + "description": "Vulnerabilities affecting only non-deployed packages in the product.", + "action": TriageAction.NOTIFY, + "precedence": 200, "rules_config": { - "unresolved_vulnerability": {"is_active": True}, + "dev_only_vulnerable_package": {"is_active": True}, }, }, { - "name": "Dev-Only Vulnerable Package", - "description": "Packages not deployed in production that are affected by vulnerabilities.", - "action": TriageAction.NOTIFY, - "precedence": 50, + "name": "Unresolved Vulnerability", + "description": ( + "Vulnerabilities affecting the product where at least one package" + " has no completed triage analysis." + ), + "action": TriageAction.FORENSIC_ANALYSIS, + "precedence": 100, "rules_config": { - "dev_only_vulnerable_package": {"is_active": True}, + "unresolved_vulnerability": {"is_active": True}, }, }, ] diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index aa5e459a..0dbeb986 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -58,7 +58,8 @@ class WeightedRiskTriageRule(BaseTriageRule): rule_type = "weighted_risk" label = "Weighted Risk" description = ( - "Vulnerabilities affecting packages whose weighted risk score meets the threshold." + "Vulnerabilities affecting at least one package whose weighted risk score" + " in this product meets the threshold." ) parameters_schema = { "min_weighted_risk_score": { @@ -116,7 +117,10 @@ def get_matching_vulnerabilities(self, product, parameters=None): class UnresolvedVulnerabilityTriageRule(BaseTriageRule): rule_type = "unresolved_vulnerability" label = "Unresolved Vulnerability" - description = "Vulnerabilities affecting the product that have no completed analysis." + description = ( + "Vulnerabilities affecting the product where at least one package" + " has no completed triage analysis." + ) def get_matching_vulnerabilities(self, product, parameters=None): Vulnerability = apps.get_model("vulnerabilities", "Vulnerability") From bff5508a3b1ee2860031e3c53a44292d53420f64 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 12 Aug 2026 08:31:34 +0400 Subject: [PATCH 47/90] add a --reset opt Signed-off-by: tdruez --- .../management/commands/create_triage_rulesets.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index 9a5b5c3e..90028c84 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -105,6 +105,14 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("dataspace", help="Name of the target Dataspace.") + parser.add_argument( + "--reset", + action="store_true", + help=( + "Delete all existing triage rulesets in the dataspace before recreating them." + " This also removes all associated product assignments and triage records." + ), + ) def handle(self, *args, **options): dataspace_name = options["dataspace"] @@ -114,6 +122,10 @@ def handle(self, *args, **options): except Dataspace.DoesNotExist: raise CommandError(f'Dataspace "{dataspace_name}" does not exist.') + if options["reset"]: + deleted_count, _ = TriageRuleset.objects.filter(dataspace=dataspace).delete() + self.stdout.write(f" Deleted {deleted_count} existing ruleset(s).") + created_count = 0 for ruleset_data in REFERENCE_RULESETS: _, created = TriageRuleset.objects.get_or_create( From af73ae9d089e4c22a657ad9ef31f982f7f4c95c9 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 12 Aug 2026 09:06:23 +0400 Subject: [PATCH 48/90] plug all the evaluation signals Signed-off-by: tdruez --- dejacode/static/css/dejacode_bootstrap.css | 3 ++ dje/tasks.py | 4 +++ vulnerabilities/triage/signals.py | 35 ++++++++++++++++++++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index 047587cb..01b1c7e9 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -419,6 +419,9 @@ table.vulnerabilities-table .column-summary { #tab_vulnerabilities .column-affected_packages { min-width: 250px; } +#tab_vulnerabilities .column-triage_action { + min-width: 165px; +} #tab_vulnerabilities .column-exploitability { width: 140px; } diff --git a/dje/tasks.py b/dje/tasks.py index 38e42e67..b9be7ec1 100644 --- a/dje/tasks.py +++ b/dje/tasks.py @@ -141,6 +141,7 @@ def scancodeio_submit_scan(uris, user_uuid, dataspace_uuid): def update_vulnerabilities(): """Fetch vulnerabilities for all Dataspaces that enable vulnerablecodedb access.""" from vulnerabilities.fetch import fetch_from_vulnerablecode + from vulnerabilities.triage.tasks import evaluate_all_products_vulnerability_triage_task logger.info("Entering update_vulnerabilities task") Dataspace = apps.get_model("dje", "Dataspace") @@ -155,3 +156,6 @@ def update_vulnerabilities(): timeout=60, log_func=logger.debug, ) + + logger.info("Vulnerability fetch complete. Enqueuing triage evaluation.") + evaluate_all_products_vulnerability_triage_task.delay() diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index 8a34845c..f997662a 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -10,14 +10,33 @@ from django.db.models.signals import post_save from django.dispatch import receiver +from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.models import ProductTriageRuleset from vulnerabilities.triage.models import TriageRecord +def reevaluate_product_rulesets(product): + """Re-evaluate all enabled triage rulesets currently assigned to the product.""" + assignments = ProductTriageRuleset.objects.filter( + product=product, ruleset__enabled=True + ).select_related("ruleset") + + for assignment in assignments: + evaluate_ruleset(ruleset=assignment.ruleset, product=product) + + @receiver(post_save, sender="vulnerabilities_triage.TriageRuleset") -def delete_triage_records_on_disable(sender, instance, **kwargs): - """Delete all triage records when a ruleset is disabled.""" +def reevaluate_or_delete_on_ruleset_save(sender, instance, created, **kwargs): + """Re-evaluate assigned products on config change; delete records when disabled.""" if not instance.enabled: instance.triage_records.all().delete() + return + + if created: + return + + for assignment in instance.product_triage_rulesets.select_related("product"): + evaluate_ruleset(ruleset=instance, product=assignment.product) @receiver(post_delete, sender="vulnerabilities_triage.ProductTriageRuleset") @@ -27,3 +46,15 @@ def delete_triage_records_on_unassign(sender, instance, **kwargs): ruleset=instance.ruleset, product=instance.product, ).delete() + + +@receiver([post_save, post_delete], sender="vulnerabilities.VulnerabilityAnalysis") +def reevaluate_on_analysis_change(sender, instance, **kwargs): + """Re-evaluate triage when an analysis state or reachability is updated.""" + reevaluate_product_rulesets(instance.product_package.product) + + +@receiver([post_save, post_delete], sender="product_portfolio.ProductPackage") +def reevaluate_on_product_package_change(sender, instance, **kwargs): + """Re-evaluate triage when a package is added, removed, or updated in a product.""" + reevaluate_product_rulesets(instance.product) From 36dbc0cfc0d3672c5b778af9177868c747d108e0 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 12 Aug 2026 09:35:52 +0400 Subject: [PATCH 49/90] dynhamic display of the recommandation column Signed-off-by: tdruez --- .../templates/product_portfolio/product_details.html | 2 +- .../tabs/tab_packages_vulnerabilities.html | 2 ++ product_portfolio/views.py | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/product_portfolio/templates/product_portfolio/product_details.html b/product_portfolio/templates/product_portfolio/product_details.html index 382707a9..e53bec3b 100644 --- a/product_portfolio/templates/product_portfolio/product_details.html +++ b/product_portfolio/templates/product_portfolio/product_details.html @@ -39,7 +39,7 @@ data-bs-toggle="modal" data-bs-target="#manage-triage-rulesets-modal" data-manage-url="{{ product.get_manage_triage_rulesets_url }}"> - {% trans "Triage Rules" %} + {% trans "Triage Rules" %} {% endif %}
diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index d7138663..3ee84848 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -74,6 +74,7 @@
{% endif %} + {% if has_triage_rulesets %} {% if vulnerability.triage_record %} {% with record=vulnerability.triage_record %} @@ -91,6 +92,7 @@ {% endwith %} {% endif %} + {% endif %} {% if vulnerability.vulnerability_analysis %} {% if vulnerability.vulnerability_analysis.state %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index ec888641..744b602f 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -1217,6 +1217,10 @@ def get_context_data(self, **kwargs): return context_data +def has_triage_column_condition(view): + return getattr(view, "has_triage_rulesets", True) + + class ProductTabVulnerabilitiesView( LoginRequiredMixin, BaseProductViewMixin, @@ -1242,6 +1246,7 @@ class ProductTabVulnerabilitiesView( _("Recommendation"), help_text=_("Action recommended by the triage engine for this vulnerability"), filter="triage_action", + condition=has_triage_column_condition, ), Header( "vulnerability_analyses__state", @@ -1406,6 +1411,10 @@ def get_context_data(self, **kwargs): anchor=f"#{self.tab_id}", ) + self.has_triage_rulesets = product.product_triage_rulesets.filter( + ruleset__enabled=True + ).exists() + # The self.filterset needs to be set before calling super() context_data = super().get_context_data(**kwargs) @@ -1423,6 +1432,7 @@ def get_context_data(self, **kwargs): "total_count": base_productpackage_qs.count(), "search_query": self.request.GET.get("vulnerabilities-q", ""), "risk_threshold": risk_threshold, + "has_triage_rulesets": self.has_triage_rulesets, } ) From 5eee37844be3675e04b986736faa230b9ec3d5ae Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 12 Aug 2026 11:07:38 +0400 Subject: [PATCH 50/90] add analisys preset system Signed-off-by: tdruez --- vulnerabilities/forms.py | 1 + ...vulnerabilityanalysis_applied_by_preset.py | 20 +++++ vulnerabilities/models.py | 23 +++++- vulnerabilities/triage/admin.py | 70 ++++++++++++++++- vulnerabilities/triage/engine.py | 74 ++++++++++++++++++ vulnerabilities/triage/forms.py | 24 +++++- .../commands/create_triage_rulesets.py | 75 +++++++++++++++++-- ...sispreset_triageruleset_analysis_preset.py | 43 +++++++++++ vulnerabilities/triage/models.py | 52 +++++++++++++ vulnerabilities/triage/signals.py | 4 +- 10 files changed, 375 insertions(+), 11 deletions(-) create mode 100644 vulnerabilities/migrations/0011_vulnerabilityanalysis_applied_by_preset.py create mode 100644 vulnerabilities/triage/migrations/0002_analysispreset_triageruleset_analysis_preset.py diff --git a/vulnerabilities/forms.py b/vulnerabilities/forms.py index 3d87d3ba..22b27e92 100644 --- a/vulnerabilities/forms.py +++ b/vulnerabilities/forms.py @@ -97,6 +97,7 @@ def helper(self): return helper def save(self, *args, **kwargs): + self.instance.applied_by_preset = None instance = super().save(*args, **kwargs) if products := self.cleaned_data.get("propagate_to_products"): diff --git a/vulnerabilities/migrations/0011_vulnerabilityanalysis_applied_by_preset.py b/vulnerabilities/migrations/0011_vulnerabilityanalysis_applied_by_preset.py new file mode 100644 index 00000000..c10caf04 --- /dev/null +++ b/vulnerabilities/migrations/0011_vulnerabilityanalysis_applied_by_preset.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.6 on 2026-08-12 06:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vulnerabilities', '0010_api_v3_remove_old_fields'), + ('vulnerabilities_triage', '0002_analysispreset_triageruleset_analysis_preset'), + ] + + operations = [ + migrations.AddField( + model_name='vulnerabilityanalysis', + name='applied_by_preset', + field=models.ForeignKey(blank=True, help_text='The analysis preset that automatically created this analysis. Cleared when a user edits the analysis manually.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applied_analyses', to='vulnerabilities_triage.analysispreset'), + ), + ] diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index b0d25a84..3e3d25ff 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -274,8 +274,8 @@ def as_cyclonedx(self, affected_instances, analysis=None): ) -class VulnerabilityAnalysisMixin(models.Model): - """Aligned with the cyclonedx.model.vulnerability.VulnerabilityAnalysis""" +class VulnerabilityAnalysisContentMixin(models.Model): + """Core analysis content fields, shared with AnalysisPreset. CycloneDX-aligned.""" # cyclonedx.model.impact_analysis.ImpactAnalysisState class State(models.TextChoices): @@ -344,6 +344,14 @@ class Response(models.TextChoices): "details on why the component or service is not impacted by this vulnerability." ), ) + + class Meta: + abstract = True + + +class VulnerabilityAnalysisMixin(VulnerabilityAnalysisContentMixin): + """Aligned with the cyclonedx.model.vulnerability.VulnerabilityAnalysis""" + first_issued = models.DateTimeField( auto_now_add=True, help_text=_("The date and time (timestamp) when the analysis was first issued."), @@ -546,6 +554,17 @@ class VulnerabilityAnalysis( "product package." ), ) + applied_by_preset = models.ForeignKey( + to="vulnerabilities_triage.AnalysisPreset", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="applied_analyses", + help_text=_( + "The analysis preset that automatically created this analysis." + " Cleared when a user edits the analysis manually." + ), + ) objects = DataspacedManager.from_queryset(ProductSecuredQuerySet)() diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 2d7abe15..11baf73b 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -12,12 +12,61 @@ from dje.admin import DataspacedAdmin from dje.admin import dejacode_site +from vulnerabilities.triage.forms import AnalysisPresetForm from vulnerabilities.triage.forms import TriageRulesetForm +from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRuleset from vulnerabilities.triage.rules import RULE_REGISTRY +@admin.register(AnalysisPreset, site=dejacode_site) +class AnalysisPresetAdmin(DataspacedAdmin): + form = AnalysisPresetForm + short_description = ( + "An Analysis Preset defines default vulnerability analysis values that the triage" + " engine applies automatically when an assigned ruleset fires." + ) + long_description = ( + "Only non-blank preset fields are applied. Analyses already modified by a human" + " are never overwritten. When a user edits an auto-applied analysis, the preset" + " link is cleared and the analysis becomes human-owned." + ) + list_display = [ + "name", + "state", + "justification", + "get_responses", + "is_reachable", + "description", + "get_dataspace", + ] + search_fields = ["name"] + fieldsets = [ + ( + None, + {"fields": ["name", "description"]}, + ), + ( + "Analysis defaults", + { + "fields": ["state", "justification", "responses", "detail", "is_reachable"], + "description": ( + "Leave a field blank to leave it unchanged on the analysis." + " At least one field should be set." + ), + }, + ), + ] + + @admin.display(description="Responses") + def get_responses(self, obj): + if not obj.responses: + return "" + labels = dict(AnalysisPreset.Response.choices) + return ", ".join(labels.get(response, response) for response in obj.responses) + + @admin.register(TriageRuleset, site=dejacode_site) class TriageRulesetAdmin(DataspacedAdmin): short_description = ( @@ -39,6 +88,7 @@ class TriageRulesetAdmin(DataspacedAdmin): "get_action_label", "precedence", "get_enabled_rules", + "analysis_preset", "description", "enabled", "get_dataspace", @@ -74,14 +124,30 @@ def get_changes_details(self, form): return super().get_changes_details(form) def get_form(self, request, obj=None, change=False, **kwargs): - kwargs["fields"] = ["name", "description", "action", "precedence", "enabled"] + kwargs["fields"] = [ + "name", + "description", + "action", + "precedence", + "enabled", + "analysis_preset", + ] return super().get_form(request, obj, change=change, **kwargs) def get_fieldsets(self, request, obj=None): base_fieldsets = [ ( None, - {"fields": ["name", "description", "action", "precedence", "enabled"]}, + { + "fields": [ + "name", + "description", + "action", + "precedence", + "enabled", + "analysis_preset", + ] + }, ), ] rule_fieldsets = [] diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index f0b4737a..09391b26 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -6,6 +6,8 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +from django.apps import apps + from vulnerabilities.triage.models import TriageRecord from vulnerabilities.triage.rules import RULE_REGISTRY @@ -37,10 +39,75 @@ def collect_matches(ruleset, product): return matched_rules_per_vulnerability_id +def apply_preset_for_vulnerabilities(preset, product, vulnerability_ids): + """ + For each (product_package, vulnerability) pair in the product, create or update + a VulnerabilityAnalysis using preset values. + + Skips any analysis already modified by a human (applied_by_preset is null on an + existing record). Only analyses that were auto-created (applied_by_preset is set) + or brand-new are touched. + """ + VulnerabilityAnalysis = apps.get_model("vulnerabilities", "VulnerabilityAnalysis") + ProductPackage = apps.get_model("product_portfolio", "ProductPackage") + + vulnerability_ids = list(vulnerability_ids) + + # One query: exact (product_package_id, vulnerability_id) pairs to process. + # Filtering by __id__in on the M2M restricts the JOIN rows to the matching + # vulnerabilities, so values_list returns only the pairs we want. + pp_vuln_pairs = set( + ProductPackage.objects.filter( + product=product, + package__affected_by_vulnerabilities__id__in=vulnerability_ids, + ) + .values_list("id", "package__affected_by_vulnerabilities__id") + .distinct() + ) + + if not pp_vuln_pairs: + return + + pp_ids = {pp_id for pp_id, _ in pp_vuln_pairs} + + # One query: all existing analyses for this product_package / vulnerability set + existing_analyses = { + (analysis.product_package_id, analysis.vulnerability_id): analysis + for analysis in VulnerabilityAnalysis.objects.filter( + product_package_id__in=pp_ids, + vulnerability_id__in=vulnerability_ids, + ) + } + + # One query: product_package instances needed to construct new analyses + product_packages_by_id = {pp.pk: pp for pp in ProductPackage.objects.filter(pk__in=pp_ids)} + + for product_package_id, vulnerability_id in pp_vuln_pairs: + existing = existing_analyses.get((product_package_id, vulnerability_id)) + + if existing is not None and existing.applied_by_preset_id is None: + continue # Human-owned analysis -- never overwrite + + if existing is None: + product_package = product_packages_by_id[product_package_id] + analysis = VulnerabilityAnalysis( + product_package=product_package, + vulnerability_id=vulnerability_id, + dataspace_id=product.dataspace_id, + ) + else: + analysis = existing + + preset.apply_to_analysis(analysis) + analysis.applied_by_preset = preset + analysis.save() + + def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id): """ Create or update one TriageRecord per matching vulnerability, then delete records for vulnerabilities that no longer match any rule in the ruleset. + Applies the ruleset's analysis_preset when configured. """ for vulnerability_id, matched_rules in matched_rules_per_vulnerability_id.items(): TriageRecord.objects.update_or_create( @@ -59,6 +126,13 @@ def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id): product=product, ).exclude(vulnerability_id__in=matched_rules_per_vulnerability_id.keys()).delete() + if ruleset.analysis_preset_id and matched_rules_per_vulnerability_id: + apply_preset_for_vulnerabilities( + preset=ruleset.analysis_preset, + product=product, + vulnerability_ids=list(matched_rules_per_vulnerability_id.keys()), + ) + def evaluate_ruleset(ruleset, product): """Evaluate a TriageRuleset against a product and persist the results.""" diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py index 851caddd..0d5728ec 100644 --- a/vulnerabilities/triage/forms.py +++ b/vulnerabilities/triage/forms.py @@ -9,11 +9,33 @@ from django import forms from dje.forms import DataspacedAdminForm +from vulnerabilities.models import VulnerabilityAnalysisContentMixin +from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRuleset from vulnerabilities.triage.rules import RULE_REGISTRY +class AnalysisPresetForm(DataspacedAdminForm): + responses = forms.MultipleChoiceField( + choices=VulnerabilityAnalysisContentMixin.Response.choices, + widget=forms.CheckboxSelectMultiple, + required=False, + ) + + class Meta: + model = AnalysisPreset + fields = [ + "name", + "description", + "state", + "justification", + "responses", + "detail", + "is_reachable", + ] + + class TriageRulesetForm(DataspacedAdminForm): action = forms.ChoiceField( choices=[("", "---------")] + list(TriageAction.choices), @@ -22,7 +44,7 @@ class TriageRulesetForm(DataspacedAdminForm): class Meta: model = TriageRuleset - fields = ["name", "description", "action", "precedence", "enabled"] + fields = ["name", "description", "action", "precedence", "enabled", "analysis_preset"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index 90028c84..73b5b0d4 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -10,6 +10,7 @@ from django.core.management.base import CommandError from dje.models import Dataspace +from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRuleset @@ -17,6 +18,38 @@ docker compose -f compose.dev.yml exec web ./manage.py create_triage_rulesets nexB """ +REFERENCE_PRESETS = [ + { + "name": "Auto-Close - Dev Only", + "description": ( + "Automatically close vulnerabilities that affect only non-deployed packages." + ), + "state": "not_affected", + "justification": "code_not_present", + "responses": ["will_not_fix"], + "detail": "Package not deployed in production. Automatically closed by triage.", + "ruleset_name": "Dev-Only Vulnerable Package", + }, + { + "name": "Flag - Active Exploit", + "description": ( + "Flag vulnerabilities with a known active exploit for immediate human review." + ), + "state": "in_triage", + "detail": "Known exploit detected. Flagged for immediate review by triage.", + "ruleset_name": "Active Exploit", + }, + { + "name": "Flag - Stale Vulnerability", + "description": ( + "Flag high-risk vulnerabilities unaddressed beyond the configured threshold." + ), + "state": "in_triage", + "detail": "Vulnerability unaddressed beyond configured threshold. Escalated by triage.", + "ruleset_name": "Stale Vulnerability", + }, +] + REFERENCE_RULESETS = [ { "name": "Critical Exploited Vulnerability", @@ -123,10 +156,13 @@ def handle(self, *args, **options): raise CommandError(f'Dataspace "{dataspace_name}" does not exist.') if options["reset"]: - deleted_count, _ = TriageRuleset.objects.filter(dataspace=dataspace).delete() - self.stdout.write(f" Deleted {deleted_count} existing ruleset(s).") + deleted_rulesets, _ = TriageRuleset.objects.filter(dataspace=dataspace).delete() + deleted_presets, _ = AnalysisPreset.objects.filter(dataspace=dataspace).delete() + self.stdout.write( + f" Deleted {deleted_rulesets} existing ruleset(s) and {deleted_presets} preset(s)." + ) - created_count = 0 + ruleset_created_count = 0 for ruleset_data in REFERENCE_RULESETS: _, created = TriageRuleset.objects.get_or_create( dataspace=dataspace, @@ -140,13 +176,42 @@ def handle(self, *args, **options): }, ) if created: - created_count += 1 + ruleset_created_count += 1 self.stdout.write(f" Created: {ruleset_data['name']}") else: self.stdout.write(f" Already exists: {ruleset_data['name']}") + preset_created_count = 0 + for preset_data in REFERENCE_PRESETS: + ruleset_name = preset_data["ruleset_name"] + preset, preset_created = AnalysisPreset.objects.get_or_create( + dataspace=dataspace, + name=preset_data["name"], + defaults={ + "description": preset_data.get("description", ""), + "state": preset_data.get("state", ""), + "justification": preset_data.get("justification", ""), + "responses": preset_data.get("responses"), + "detail": preset_data.get("detail", ""), + }, + ) + if preset_created: + preset_created_count += 1 + self.stdout.write(f" Created preset: {preset_data['name']}") + else: + self.stdout.write(f" Already exists: {preset_data['name']}") + try: + ruleset = TriageRuleset.objects.get(dataspace=dataspace, name=ruleset_name) + if ruleset.analysis_preset_id != preset.pk: + ruleset.analysis_preset = preset + ruleset.save(update_fields=["analysis_preset"]) + self.stdout.write(f" Linked preset to ruleset: {ruleset_name}") + except TriageRuleset.DoesNotExist: + self.stdout.write(f" Ruleset not found: {ruleset_name}") + self.stdout.write( self.style.SUCCESS( - f"{created_count} ruleset(s) created in dataspace '{dataspace_name}'." + f"{ruleset_created_count} ruleset(s) and {preset_created_count} preset(s)" + f" created in dataspace '{dataspace_name}'." ) ) diff --git a/vulnerabilities/triage/migrations/0002_analysispreset_triageruleset_analysis_preset.py b/vulnerabilities/triage/migrations/0002_analysispreset_triageruleset_analysis_preset.py new file mode 100644 index 00000000..a9224652 --- /dev/null +++ b/vulnerabilities/triage/migrations/0002_analysispreset_triageruleset_analysis_preset.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0.6 on 2026-08-12 06:37 + +import django.contrib.postgres.fields +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'), + ('vulnerabilities_triage', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AnalysisPreset', + 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')), + ('state', models.CharField(blank=True, choices=[('resolved', 'Resolved'), ('resolved_with_pedigree', 'Resolved With Pedigree'), ('exploitable', 'Exploitable'), ('in_triage', 'In Triage'), ('false_positive', 'False Positive'), ('not_affected', 'Not Affected')], help_text='Declares the current state of an occurrence of a vulnerability, after automated or manual analysis.', max_length=25)), + ('justification', models.CharField(blank=True, choices=[('code_not_present', 'Code Not Present'), ('code_not_reachable', 'Code Not Reachable'), ('protected_at_perimeter', 'Protected At Perimeter'), ('protected_at_runtime', 'Protected At Runtime'), ('protected_by_compiler', 'Protected By Compiler'), ('protected_by_mitigating_control', 'Protected By Mitigating Control'), ('requires_configuration', 'Requires Configuration'), ('requires_dependency', 'Requires Dependency'), ('requires_environment', 'Requires Environment')], help_text='The rationale of why the impact analysis state was asserted.', max_length=35)), + ('responses', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('can_not_fix', 'Can Not Fix'), ('rollback', 'Rollback'), ('update', 'Update'), ('will_not_fix', 'Will Not Fix'), ('workaround_available', 'Workaround Available')], max_length=20), blank=True, help_text='A response to the vulnerability by the manufacturer, supplier, or project responsible for the affected component or service. More than one response is allowed. Responses are strongly encouraged for vulnerabilities where the analysis state is exploitable.', null=True)), + ('detail', models.TextField(blank=True, help_text='Detailed description of the impact including methods used during assessment. If a vulnerability is not exploitable, this field should include specific details on why the component or service is not impacted by this vulnerability.')), + ('name', models.CharField(help_text='Short name identifying this analysis preset.', max_length=100)), + ('description', models.TextField(blank=True, help_text='Optional description of when and why this preset is applied.')), + ('is_reachable', models.BooleanField(blank=True, help_text='Reachability value to set on the analysis. Leave blank to leave the field unchanged.', null=True)), + ('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')), + ], + options={ + 'ordering': ('name',), + 'unique_together': {('dataspace', 'name'), ('dataspace', 'uuid')}, + }, + bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), + ), + migrations.AddField( + model_name='triageruleset', + name='analysis_preset', + field=models.ForeignKey(blank=True, help_text='Optional preset automatically applied to matching vulnerability analyses. Only applied when no human-owned analysis exists.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_rulesets', to='vulnerabilities_triage.analysispreset'), + ), + ] diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index af9ee84b..d02fe37e 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -15,6 +15,7 @@ from dje.models import DataspacedManager from dje.models import DataspacedModel from dje.models import ProductSecuredQuerySet +from vulnerabilities.models import VulnerabilityAnalysisContentMixin class TriageAction(models.TextChoices): @@ -28,6 +29,46 @@ class TriageAction(models.TextChoices): CREATE_REQUEST = "create_request", _("Create DejaCode Request") +class AnalysisPreset(DataspacedModel, VulnerabilityAnalysisContentMixin): + """Default VulnerabilityAnalysis values applied automatically when a TriageRuleset fires.""" + + name = models.CharField( + max_length=100, + help_text=_("Short name identifying this analysis preset."), + ) + description = models.TextField( + blank=True, + help_text=_("Optional description of when and why this preset is applied."), + ) + is_reachable = models.BooleanField( + null=True, + blank=True, + help_text=_( + "Reachability value to set on the analysis. Leave blank to leave the field unchanged." + ), + ) + + class Meta: + unique_together = (("dataspace", "name"), ("dataspace", "uuid")) + ordering = ("name",) + + def __str__(self): + return self.name + + def apply_to_analysis(self, analysis): + """Copy non-blank preset fields onto the analysis instance (does not save).""" + if self.state: + analysis.state = self.state + if self.justification: + analysis.justification = self.justification + if self.responses: + analysis.responses = self.responses + if self.detail: + analysis.detail = self.detail + if self.is_reachable is not None: + analysis.is_reachable = self.is_reachable + + class TriageRuleset(DataspacedModel): name = models.CharField( max_length=100, @@ -58,6 +99,17 @@ class TriageRuleset(DataspacedModel): blank=True, help_text=_("Active rules for this ruleset, keyed by rule type."), ) + analysis_preset = models.ForeignKey( + to="AnalysisPreset", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="triage_rulesets", + help_text=_( + "Optional preset automatically applied to matching vulnerability analyses." + " Only applied when no human-owned analysis exists." + ), + ) class Meta: unique_together = ( diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index f997662a..462699ff 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -19,7 +19,7 @@ def reevaluate_product_rulesets(product): """Re-evaluate all enabled triage rulesets currently assigned to the product.""" assignments = ProductTriageRuleset.objects.filter( product=product, ruleset__enabled=True - ).select_related("ruleset") + ).select_related("ruleset", "ruleset__analysis_preset") for assignment in assignments: evaluate_ruleset(ruleset=assignment.ruleset, product=product) @@ -51,6 +51,8 @@ def delete_triage_records_on_unassign(sender, instance, **kwargs): @receiver([post_save, post_delete], sender="vulnerabilities.VulnerabilityAnalysis") def reevaluate_on_analysis_change(sender, instance, **kwargs): """Re-evaluate triage when an analysis state or reachability is updated.""" + if instance.applied_by_preset_id: + return # Written by the triage engine itself -- re-evaluating would loop reevaluate_product_rulesets(instance.product_package.product) From ce2d41312e8c1903381aaf797d84a3cf3742680f Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 12 Aug 2026 15:55:32 +0400 Subject: [PATCH 51/90] bug fixes Signed-off-by: tdruez --- dje/cron_jobs.py | 2 +- .../tabs/tab_packages_vulnerabilities.html | 2 +- product_portfolio/views.py | 22 ++-- vulnerabilities/triage/admin.py | 3 +- vulnerabilities/triage/engine.py | 101 +++++++++++++----- vulnerabilities/triage/rules.py | 5 + vulnerabilities/triage/signals.py | 30 +++++- 7 files changed, 123 insertions(+), 42 deletions(-) diff --git a/dje/cron_jobs.py b/dje/cron_jobs.py index 575af4e1..45a802a2 100644 --- a/dje/cron_jobs.py +++ b/dje/cron_jobs.py @@ -35,5 +35,5 @@ func=evaluate_all_products_vulnerability_triage_task, queue_name="default", cron=settings.DEJACODE_VULNERABILITY_TRIAGE_CRON, # Hourly by default - job_timeout=ten_minutes, + job_timeout=two_hours, ) diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index 3ee84848..7b814448 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -144,7 +144,7 @@ {% endif %} {% empty %} - + No results. {% if filterset.is_active %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 744b602f..c91004eb 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -156,6 +156,7 @@ from vulnerabilities.triage.models import TriageRecord from vulnerabilities.triage.models import TriageRuleset from vulnerabilities.triage.rules import RULE_REGISTRY as TRIAGE_RULE_REGISTRY +from vulnerabilities.triage.rules import rule_parameters_from_config TRIAGE_ACTION_STYLES = { "upgrade": ("bg-danger-subtle text-danger-emphasis", "fa-arrow-circle-up"), @@ -2224,19 +2225,20 @@ def manage_triage_rulesets_view(request, dataspace, name, version=""): submitted_uuids = set(request.POST.getlist("ruleset_uuids")) current_assignments = { str(ptr.ruleset.uuid): ptr - for ptr in ProductTriageRuleset.objects.filter(product=product).select_related( - "ruleset" - ) + for ptr in ProductTriageRuleset.objects.filter( + product=product, ruleset__enabled=True + ).select_related("ruleset") } for ruleset in available_rulesets: ruleset_uuid = str(ruleset.uuid) if ruleset_uuid in submitted_uuids and ruleset_uuid not in current_assignments: - ProductTriageRuleset.objects.create( - product=product, - ruleset=ruleset, - dataspace=product.dataspace, - ) - evaluate_ruleset(ruleset=ruleset, product=product) + with transaction.atomic(): + ProductTriageRuleset.objects.create( + product=product, + ruleset=ruleset, + dataspace=product.dataspace, + ) + evaluate_ruleset(ruleset=ruleset, product=product) for ruleset_uuid, assignment in current_assignments.items(): if ruleset_uuid not in submitted_uuids: assignment.delete() @@ -2257,7 +2259,7 @@ def manage_triage_rulesets_view(request, dataspace, name, version=""): if rule_type not in TRIAGE_RULE_REGISTRY or not config.get("is_active"): continue handler = TRIAGE_RULE_REGISTRY[rule_type] - params = {key: value for key, value in config.items() if key != "is_active"} + params = rule_parameters_from_config(config) params_str = ", ".join( f"{key.replace('_', ' ')}: {value}" for key, value in params.items() ) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 11baf73b..3ccc1e29 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -18,6 +18,7 @@ from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRuleset from vulnerabilities.triage.rules import RULE_REGISTRY +from vulnerabilities.triage.rules import rule_parameters_from_config @admin.register(AnalysisPreset, site=dejacode_site) @@ -107,7 +108,7 @@ def get_enabled_rules(self, obj): if rule_type not in RULE_REGISTRY or not config.get("is_active"): continue handler = RULE_REGISTRY[rule_type] - params = {key: value for key, value in config.items() if key != "is_active"} + params = rule_parameters_from_config(config) if params: param_str = ", ".join(f"{key}: {value}" for key, value in params.items()) label = f"{handler.label} ({param_str})" diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index 09391b26..a46d541e 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -7,9 +7,11 @@ # from django.apps import apps +from django.utils import timezone from vulnerabilities.triage.models import TriageRecord from vulnerabilities.triage.rules import RULE_REGISTRY +from vulnerabilities.triage.rules import rule_parameters_from_config def collect_matches(ruleset, product): @@ -27,7 +29,7 @@ def collect_matches(ruleset, product): if not handler: continue - parameters = {key: value for key, value in config.items() if key != "is_active"} + parameters = rule_parameters_from_config(config) matching_vulnerability_ids = handler.get_matching_vulnerabilities( product=product, parameters=parameters, @@ -46,17 +48,17 @@ def apply_preset_for_vulnerabilities(preset, product, vulnerability_ids): Skips any analysis already modified by a human (applied_by_preset is null on an existing record). Only analyses that were auto-created (applied_by_preset is set) - or brand-new are touched. + or brand-new are touched. Skips creating a new analysis when the preset has no + content fields set (state/justification/responses/detail), since saving an + analysis with only is_reachable would fail model validation. """ VulnerabilityAnalysis = apps.get_model("vulnerabilities", "VulnerabilityAnalysis") ProductPackage = apps.get_model("product_portfolio", "ProductPackage") - vulnerability_ids = list(vulnerability_ids) - # One query: exact (product_package_id, vulnerability_id) pairs to process. # Filtering by __id__in on the M2M restricts the JOIN rows to the matching # vulnerabilities, so values_list returns only the pairs we want. - pp_vuln_pairs = set( + product_package_vulnerability_pairs = set( ProductPackage.objects.filter( product=product, package__affected_by_vulnerabilities__id__in=vulnerability_ids, @@ -65,24 +67,27 @@ def apply_preset_for_vulnerabilities(preset, product, vulnerability_ids): .distinct() ) - if not pp_vuln_pairs: + if not product_package_vulnerability_pairs: return - pp_ids = {pp_id for pp_id, _ in pp_vuln_pairs} + product_package_ids = {pair_pp_id for pair_pp_id, _ in product_package_vulnerability_pairs} # One query: all existing analyses for this product_package / vulnerability set existing_analyses = { (analysis.product_package_id, analysis.vulnerability_id): analysis for analysis in VulnerabilityAnalysis.objects.filter( - product_package_id__in=pp_ids, + product_package_id__in=product_package_ids, vulnerability_id__in=vulnerability_ids, ) } # One query: product_package instances needed to construct new analyses - product_packages_by_id = {pp.pk: pp for pp in ProductPackage.objects.filter(pk__in=pp_ids)} + product_packages_by_id = { + product_package.pk: product_package + for product_package in ProductPackage.objects.filter(pk__in=product_package_ids) + } - for product_package_id, vulnerability_id in pp_vuln_pairs: + for product_package_id, vulnerability_id in product_package_vulnerability_pairs: existing = existing_analyses.get((product_package_id, vulnerability_id)) if existing is not None and existing.applied_by_preset_id is None: @@ -95,38 +100,85 @@ def apply_preset_for_vulnerabilities(preset, product, vulnerability_ids): vulnerability_id=vulnerability_id, dataspace_id=product.dataspace_id, ) + preset.apply_to_analysis(analysis) + content_fields = [ + analysis.state, + analysis.justification, + analysis.responses, + analysis.detail, + ] + if not any(content_fields): + continue # Preset has no content fields -- cannot save a new analysis else: analysis = existing + preset.apply_to_analysis(analysis) - preset.apply_to_analysis(analysis) analysis.applied_by_preset = preset analysis.save() -def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id): +def delete_preset_analyses_for_product(preset_id, product, vulnerability_ids): + """ + Delete VulnerabilityAnalysis records applied by the given preset for the given + product and vulnerability set. + """ + VulnerabilityAnalysis = apps.get_model("vulnerabilities", "VulnerabilityAnalysis") + ProductPackage = apps.get_model("product_portfolio", "ProductPackage") + + product_package_ids = list( + ProductPackage.objects.filter(product=product).values_list("id", flat=True) + ) + VulnerabilityAnalysis.objects.filter( + product_package_id__in=product_package_ids, + vulnerability_id__in=vulnerability_ids, + applied_by_preset_id=preset_id, + ).delete() + + +def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id, apply_preset=True): """ Create or update one TriageRecord per matching vulnerability, then delete records for vulnerabilities that no longer match any rule in the ruleset. - Applies the ruleset's analysis_preset when configured. + Applies the ruleset's analysis_preset when configured and apply_preset is True. """ - for vulnerability_id, matched_rules in matched_rules_per_vulnerability_id.items(): - TriageRecord.objects.update_or_create( + now = timezone.now() + records = [ + TriageRecord( vulnerability_id=vulnerability_id, product=product, ruleset=ruleset, - defaults={ - "action": ruleset.action, - "matched_rules": matched_rules, - "dataspace": ruleset.dataspace, - }, + action=ruleset.action, + matched_rules=matched_rules, + dataspace=ruleset.dataspace, + detected_date=now, + last_checked=now, ) + for vulnerability_id, matched_rules in matched_rules_per_vulnerability_id.items() + ] + TriageRecord.objects.bulk_create( + records, + update_conflicts=True, + unique_fields=["vulnerability", "product", "ruleset"], + update_fields=["action", "matched_rules", "last_checked", "dataspace"], + ) - TriageRecord.objects.filter( + stale_records_qs = TriageRecord.objects.filter( ruleset=ruleset, product=product, - ).exclude(vulnerability_id__in=matched_rules_per_vulnerability_id.keys()).delete() + ).exclude(vulnerability_id__in=matched_rules_per_vulnerability_id.keys()) + + if ruleset.analysis_preset_id: + stale_vulnerability_ids = list(stale_records_qs.values_list("vulnerability_id", flat=True)) + if stale_vulnerability_ids: + delete_preset_analyses_for_product( + preset_id=ruleset.analysis_preset_id, + product=product, + vulnerability_ids=stale_vulnerability_ids, + ) + + stale_records_qs.delete() - if ruleset.analysis_preset_id and matched_rules_per_vulnerability_id: + if apply_preset and ruleset.analysis_preset_id and matched_rules_per_vulnerability_id: apply_preset_for_vulnerabilities( preset=ruleset.analysis_preset, product=product, @@ -134,11 +186,12 @@ def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id): ) -def evaluate_ruleset(ruleset, product): +def evaluate_ruleset(ruleset, product, apply_preset=True): """Evaluate a TriageRuleset against a product and persist the results.""" matched_rules_per_vulnerability_id = collect_matches(ruleset=ruleset, product=product) sync_triage_records( ruleset=ruleset, product=product, matched_rules_per_vulnerability_id=matched_rules_per_vulnerability_id, + apply_preset=apply_preset, ) diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index 0dbeb986..6dd7d437 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -234,3 +234,8 @@ def get_matching_vulnerabilities(self, product, parameters=None): StaleVulnerabilityTriageRule.rule_type: StaleVulnerabilityTriageRule(), DevOnlyPackageTriageRule.rule_type: DevOnlyPackageTriageRule(), } + + +def rule_parameters_from_config(config): + """Extract rule-specific parameters from a rule config dict, excluding is_active.""" + return {key: value for key, value in config.items() if key != "is_active"} diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index 462699ff..b1f9170a 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -10,19 +10,20 @@ from django.db.models.signals import post_save from django.dispatch import receiver +from vulnerabilities.triage.engine import delete_preset_analyses_for_product from vulnerabilities.triage.engine import evaluate_ruleset from vulnerabilities.triage.models import ProductTriageRuleset from vulnerabilities.triage.models import TriageRecord -def reevaluate_product_rulesets(product): +def reevaluate_product_rulesets(product, apply_preset=True): """Re-evaluate all enabled triage rulesets currently assigned to the product.""" assignments = ProductTriageRuleset.objects.filter( product=product, ruleset__enabled=True ).select_related("ruleset", "ruleset__analysis_preset") for assignment in assignments: - evaluate_ruleset(ruleset=assignment.ruleset, product=product) + evaluate_ruleset(ruleset=assignment.ruleset, product=product, apply_preset=apply_preset) @receiver(post_save, sender="vulnerabilities_triage.TriageRuleset") @@ -41,19 +42,38 @@ def reevaluate_or_delete_on_ruleset_save(sender, instance, created, **kwargs): @receiver(post_delete, sender="vulnerabilities_triage.ProductTriageRuleset") def delete_triage_records_on_unassign(sender, instance, **kwargs): - """Delete triage records for a ruleset when it is de-assigned from a product.""" + """Delete triage records and associated preset analyses when a ruleset is de-assigned.""" + stale_vulnerability_ids = list( + TriageRecord.objects.filter( + ruleset=instance.ruleset, + product=instance.product, + ).values_list("vulnerability_id", flat=True) + ) TriageRecord.objects.filter( ruleset=instance.ruleset, product=instance.product, ).delete() + if instance.ruleset.analysis_preset_id and stale_vulnerability_ids: + delete_preset_analyses_for_product( + preset_id=instance.ruleset.analysis_preset_id, + product=instance.product, + vulnerability_ids=stale_vulnerability_ids, + ) @receiver([post_save, post_delete], sender="vulnerabilities.VulnerabilityAnalysis") def reevaluate_on_analysis_change(sender, instance, **kwargs): """Re-evaluate triage when an analysis state or reachability is updated.""" - if instance.applied_by_preset_id: + signal = kwargs.get("signal") + if signal == post_save and instance.applied_by_preset_id: return # Written by the triage engine itself -- re-evaluating would loop - reevaluate_product_rulesets(instance.product_package.product) + # When a human explicitly deletes their analysis, skip preset application to avoid + # having the engine immediately recreate it. + is_human_delete = signal == post_delete and not instance.applied_by_preset_id + reevaluate_product_rulesets( + instance.product_package.product, + apply_preset=not is_human_delete, + ) @receiver([post_save, post_delete], sender="product_portfolio.ProductPackage") From f50d8b53ccf1a1d6d9168131db26689457c4e3a6 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Aug 2026 10:01:38 +0200 Subject: [PATCH 52/90] add request to the triage system Signed-off-by: tdruez --- ..._request_triageruleset_request_template.py | 25 +++++++++++++++++++ vulnerabilities/triage/models.py | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 vulnerabilities/triage/migrations/0003_triagerecord_request_triageruleset_request_template.py diff --git a/vulnerabilities/triage/migrations/0003_triagerecord_request_triageruleset_request_template.py b/vulnerabilities/triage/migrations/0003_triagerecord_request_triageruleset_request_template.py new file mode 100644 index 00000000..472f0f2f --- /dev/null +++ b/vulnerabilities/triage/migrations/0003_triagerecord_request_triageruleset_request_template.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.6 on 2026-08-13 08:01 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vulnerabilities_triage', '0002_analysispreset_triageruleset_analysis_preset'), + ('workflow', '0003_externalissuelink_base_url'), + ] + + operations = [ + migrations.AddField( + model_name='triagerecord', + name='request', + field=models.ForeignKey(blank=True, help_text='Request automatically opened by the triage engine for this vulnerability match.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_records', to='workflow.request'), + ), + migrations.AddField( + model_name='triageruleset', + name='request_template', + field=models.ForeignKey(blank=True, help_text='Optional product-type request template. When set, the triage engine automatically opens a request for each newly detected vulnerability match.', limit_choices_to={'content_type__app_label': 'product_portfolio', 'content_type__model': 'product'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_rulesets', to='workflow.requesttemplate'), + ), + ] diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index d02fe37e..b8107fb6 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -110,6 +110,21 @@ class TriageRuleset(DataspacedModel): " Only applied when no human-owned analysis exists." ), ) + request_template = models.ForeignKey( + to="workflow.RequestTemplate", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="triage_rulesets", + limit_choices_to={ + "content_type__app_label": "product_portfolio", + "content_type__model": "product", + }, + help_text=_( + "Optional product-type request template. When set, the triage engine" + " automatically opens a request for each newly detected vulnerability match." + ), + ) class Meta: unique_together = ( @@ -210,6 +225,16 @@ class TriageRecord(DataspacedModel): default=list, help_text=_("Rules that fired for this vulnerability during evaluation."), ) + request = models.ForeignKey( + to="workflow.Request", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="triage_records", + help_text=_( + "Request automatically opened by the triage engine for this vulnerability match." + ), + ) detected_date = models.DateTimeField( auto_now_add=True, help_text=_("Date and time when this recommendation was first generated."), From 30fce9166dbae95a714e9d6681156ccc87cb1185 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Aug 2026 10:11:17 +0200 Subject: [PATCH 53/90] add new fields in admin Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 3ccc1e29..3233214c 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -12,6 +12,7 @@ from dje.admin import DataspacedAdmin from dje.admin import dejacode_site +from dje.list_display import AsLink from vulnerabilities.triage.forms import AnalysisPresetForm from vulnerabilities.triage.forms import TriageRulesetForm from vulnerabilities.triage.models import AnalysisPreset @@ -23,7 +24,6 @@ @admin.register(AnalysisPreset, site=dejacode_site) class AnalysisPresetAdmin(DataspacedAdmin): - form = AnalysisPresetForm short_description = ( "An Analysis Preset defines default vulnerability analysis values that the triage" " engine applies automatically when an assigned ruleset fires." @@ -33,6 +33,7 @@ class AnalysisPresetAdmin(DataspacedAdmin): " are never overwritten. When a user edits an auto-applied analysis, the preset" " link is cleared and the analysis becomes human-owned." ) + form = AnalysisPresetForm list_display = [ "name", "state", @@ -64,6 +65,7 @@ class AnalysisPresetAdmin(DataspacedAdmin): def get_responses(self, obj): if not obj.responses: return "" + labels = dict(AnalysisPreset.Response.choices) return ", ".join(labels.get(response, response) for response in obj.responses) @@ -82,14 +84,14 @@ class TriageRulesetAdmin(DataspacedAdmin): " product; when conditions overlap, the ruleset with the highest precedence takes" " effect." ) - form = TriageRulesetForm list_display = [ "name", "get_action_label", "precedence", "get_enabled_rules", - "analysis_preset", + AsLink("analysis_preset"), + AsLink("request_template"), "description", "enabled", "get_dataspace", @@ -115,13 +117,19 @@ def get_enabled_rules(self, obj): else: label = handler.label lines.append(escape(label)) + if not lines: return "" + return mark_safe("
".join(lines)) def get_changes_details(self, form): model_field_names = {field.name for field in TriageRuleset._meta.get_fields()} - form.__dict__["changed_data"] = [f for f in form.changed_data if f in model_field_names] + form.__dict__["changed_data"] = [ + field_name + for field_name in form.changed_data + if field_name in model_field_names + ] return super().get_changes_details(form) def get_form(self, request, obj=None, change=False, **kwargs): @@ -132,6 +140,7 @@ def get_form(self, request, obj=None, change=False, **kwargs): "precedence", "enabled", "analysis_preset", + "request_template", ] return super().get_form(request, obj, change=change, **kwargs) @@ -147,10 +156,12 @@ def get_fieldsets(self, request, obj=None): "precedence", "enabled", "analysis_preset", + "request_template", ] }, ), ] + rule_fieldsets = [] for rule_type, handler in RULE_REGISTRY.items(): fields = [f"rule_{rule_type}_enabled"] @@ -166,4 +177,5 @@ def get_fieldsets(self, request, obj=None): }, ) ) + return base_fieldsets + rule_fieldsets From dff860ffc5c5279ea41d8f6d97b433dfd797a772 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Aug 2026 10:12:51 +0200 Subject: [PATCH 54/90] add new fields in form Signed-off-by: tdruez --- product_portfolio/views.py | 2 +- vulnerabilities/triage/forms.py | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index c91004eb..f9979ac6 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -1345,7 +1345,7 @@ def attach_triage_data(self, product, page_obj): vulnerability_id__in=vulnerability_ids, ) .primary_actions() - .select_related("ruleset") + .select_related("ruleset", "request") ) for record in triage_records: record.action_label = action_labels.get(record.action, record.action) diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py index 0d5728ec..6de9ee75 100644 --- a/vulnerabilities/triage/forms.py +++ b/vulnerabilities/triage/forms.py @@ -44,7 +44,15 @@ class TriageRulesetForm(DataspacedAdminForm): class Meta: model = TriageRuleset - fields = ["name", "description", "action", "precedence", "enabled", "analysis_preset"] + fields = [ + "name", + "description", + "action", + "precedence", + "enabled", + "analysis_preset", + "request_template", + ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) From 2df2f7e384d1f637d470f83bca5c37aa3a3c16ff Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Aug 2026 10:38:08 +0200 Subject: [PATCH 55/90] display the request status and link in the recommandation cell Signed-off-by: tdruez --- .../tabs/tab_packages_vulnerabilities.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index 7b814448..bfe7d0f3 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -86,6 +86,14 @@
{{ record.ruleset.name }}
+ {% if record.request %} +
+ {% endif %}
{% trans "Since:" %} {{ record.detected_date|date:"M j, Y" }}
From c92b44688269c8d13f3822f353cb6004e8a00ddb Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Aug 2026 12:10:05 +0200 Subject: [PATCH 56/90] open a request for each TriageRecord when request_template is set Signed-off-by: tdruez --- vulnerabilities/triage/engine.py | 36 +++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index a46d541e..c8947967 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -108,7 +108,7 @@ def apply_preset_for_vulnerabilities(preset, product, vulnerability_ids): analysis.detail, ] if not any(content_fields): - continue # Preset has no content fields -- cannot save a new analysis + continue # Preset has no content fields - cannot save a new analysis else: analysis = existing preset.apply_to_analysis(analysis) @@ -185,6 +185,40 @@ def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id, ap vulnerability_ids=list(matched_rules_per_vulnerability_id.keys()), ) + # Open a workflow Request for each TriageRecord when the Ruleset request_template is set. + if ruleset.request_template_id: + records_without_request = TriageRecord.objects.filter( + ruleset=ruleset, + product=product, + request=None, + ).select_related("vulnerability") + + if records_without_request: + create_triage_requests( + request_template=ruleset.request_template, + product=product, + records=records_without_request, + ) + + +def create_triage_requests(request_template, product, records): + """ + Open one Request per TriageRecord using the given request_template. + + Uses the request_template creator as requester. + """ + requester = request_template.created_by + + for record in records: + advisory_id = record.vulnerability.advisory_id + triage_request = request_template.create_request( + requester=requester, + title=f"Vulnerability: {advisory_id}", + product_context=product, + object_id=product.pk, + ) + TriageRecord.objects.filter(pk=record.pk).update(request=triage_request) + def evaluate_ruleset(ruleset, product, apply_preset=True): """Evaluate a TriageRuleset against a product and persist the results.""" From ca6764329b95bd370a5752cc2e745c9fe6def6e6 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Aug 2026 15:43:26 +0200 Subject: [PATCH 57/90] move the validation on the mixin Signed-off-by: tdruez --- vulnerabilities/models.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 3e3d25ff..046e9221 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -345,6 +345,21 @@ class Response(models.TextChoices): ), ) + def save(self, *args, **kwargs): + # At least one of those fields must be provided. + main_fields = [ + self.state, + self.justification, + self.responses, + self.detail, + ] + if not any(main_fields): + raise ValueError( + "At least one of state, justification, responses or detail must be provided." + ) + + super().save(*args, **kwargs) + class Meta: abstract = True @@ -364,21 +379,6 @@ class VulnerabilityAnalysisMixin(VulnerabilityAnalysisContentMixin): class Meta: abstract = True - def save(self, *args, **kwargs): - # At least one of those fields must be provided. - main_fields = [ - self.state, - self.justification, - self.responses, - self.detail, - ] - if not any(main_fields): - raise ValueError( - "At least one of state, justification, responses or detail must be provided." - ) - - super().save(*args, **kwargs) - def as_cyclonedx(self): state = None if self.state: From 97bb661ec76fed921cba9ec36315dc595d42ff73 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Aug 2026 16:25:57 +0200 Subject: [PATCH 58/90] refinements Signed-off-by: tdruez --- product_portfolio/views.py | 2 +- vulnerabilities/triage/admin.py | 4 +--- vulnerabilities/triage/models.py | 23 ++++------------------- 3 files changed, 6 insertions(+), 23 deletions(-) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index f9979ac6..8501d911 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -1344,7 +1344,7 @@ def attach_triage_data(self, product, page_obj): product=product, vulnerability_id__in=vulnerability_ids, ) - .primary_actions() + .highest_precedence() .select_related("ruleset", "request") ) for record in triage_records: diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 3233214c..8f49b896 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -126,9 +126,7 @@ def get_enabled_rules(self, obj): def get_changes_details(self, form): model_field_names = {field.name for field in TriageRuleset._meta.get_fields()} form.__dict__["changed_data"] = [ - field_name - for field_name in form.changed_data - if field_name in model_field_names + field_name for field_name in form.changed_data if field_name in model_field_names ] return super().get_changes_details(form) diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index b8107fb6..91a44831 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -6,7 +6,6 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # -from django.apps import apps from django.db import models from django.db.models import OuterRef from django.db.models import Subquery @@ -57,14 +56,9 @@ def __str__(self): def apply_to_analysis(self, analysis): """Copy non-blank preset fields onto the analysis instance (does not save).""" - if self.state: - analysis.state = self.state - if self.justification: - analysis.justification = self.justification - if self.responses: - analysis.responses = self.responses - if self.detail: - analysis.detail = self.detail + for field_name in ("state", "justification", "responses", "detail"): + if value := getattr(self, field_name): + setattr(analysis, field_name, value) if self.is_reachable is not None: analysis.is_reachable = self.is_reachable @@ -84,7 +78,6 @@ class TriageRuleset(DataspacedModel): help_text=_("Action recommended when this ruleset's conditions are met."), ) precedence = models.PositiveIntegerField( - default=100, help_text=_( "When multiple rulesets are assigned to a product and produce different" " actions, the one with the highest precedence takes effect." @@ -139,15 +132,7 @@ def __str__(self): class TriageRecordQuerySet(ProductSecuredQuerySet): - def product_secured(self, user=None, perms="view_product"): - """Filter by product object permission through the direct product FK.""" - if not user: - return self.none() - Product = apps.get_model("product_portfolio", "Product") - product_qs = Product.objects.get_queryset(user, perms) - return self.filter(product__in=product_qs) - - def primary_actions(self): + def highest_precedence(self): """ Return one record per (vulnerability, product): the highest-precedence active ruleset that is explicitly assigned to the product via ProductTriageRuleset. From 5a2a09c8abb282ad30b2ab9853129e57cbc00502 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Aug 2026 16:52:50 +0200 Subject: [PATCH 59/90] add ability to set a analysis preset Signed-off-by: tdruez --- .../product_portfolio/product_details.html | 15 +++++++ .../tabs/tab_packages_vulnerabilities.html | 20 ++++++++- product_portfolio/urls.py | 6 +++ product_portfolio/views.py | 42 +++++++++++++++++++ 4 files changed, 82 insertions(+), 1 deletion(-) diff --git a/product_portfolio/templates/product_portfolio/product_details.html b/product_portfolio/templates/product_portfolio/product_details.html index e53bec3b..b093ccf8 100644 --- a/product_portfolio/templates/product_portfolio/product_details.html +++ b/product_portfolio/templates/product_portfolio/product_details.html @@ -384,6 +384,21 @@ }); }); + document.addEventListener('click', function(event) { + const btn = event.target.closest('.quick-preset-btn'); + if (!btn) return; + fetch(btn.dataset.applyUrl, { + method: 'POST', + headers: {'X-CSRFToken': csrftoken}, + }) + .then(response => response.json()) + .then(data => { + if (data.success) location.reload(); + else alert(data.error || 'Error applying preset.'); + }) + .catch(() => alert('Error applying preset.')); + }); + }); {% endif %} diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index bfe7d0f3..37f5a00e 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -134,7 +134,7 @@ {% endif %} - + + {% if analysis_presets and not vulnerability.vulnerability_analysis %} + + {% endif %} {% if not forloop.first %}{% endif %} {% endfor %} diff --git a/product_portfolio/urls.py b/product_portfolio/urls.py index 18c6a6e2..7cfa0675 100644 --- a/product_portfolio/urls.py +++ b/product_portfolio/urls.py @@ -39,6 +39,7 @@ from product_portfolio.views import ProductUpdateView from product_portfolio.views import PullProjectDataFromScanCodeIOView from product_portfolio.views import add_customcomponent_ajax_view +from product_portfolio.views import apply_analysis_preset_view 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 @@ -118,6 +119,11 @@ def product_path(path_segment, view): edit_productrelation_ajax_view, name="edit_productrelation_ajax", ), + path( + "vulnerability_analysis//preset///", + apply_analysis_preset_view, + name="apply_analysis_preset", + ), path( "vulnerability_analysis///", vulnerability_analysis_form_view, diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 8501d911..f77d4be6 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -151,6 +151,7 @@ from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.models import get_risk_level from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import ProductTriageRuleset from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRecord @@ -1426,6 +1427,7 @@ def get_context_data(self, **kwargs): self.attach_vulnerability_analyses(page_obj) self.attach_triage_data(product, page_obj) + analysis_presets = list(AnalysisPreset.objects.scope(product.dataspace)) context_data.update( { "filterset": self.filterset, @@ -1434,6 +1436,7 @@ def get_context_data(self, **kwargs): "search_query": self.request.GET.get("vulnerabilities-q", ""), "risk_threshold": risk_threshold, "has_triage_rulesets": self.has_triage_rulesets, + "analysis_presets": analysis_presets, } ) @@ -2934,6 +2937,45 @@ def vulnerability_analysis_form_view(request, productpackage_uuid, advisory_uid) return HttpResponse(rendered_form) +@login_required +@require_POST +def apply_analysis_preset_view(request, productpackage_uuid, advisory_uid, preset_id): + user = request.user + dataspace = user.dataspace + + product_package_qs = ProductPackage.objects.product_secured(user, perms="change_product") + product_package = get_object_or_404(product_package_qs, uuid=productpackage_uuid) + vulnerability = get_object_or_404( + Vulnerability.objects.scope(dataspace), advisory_uid=advisory_uid + ) + preset = get_object_or_404(AnalysisPreset.objects.scope(dataspace), pk=preset_id) + + existing = VulnerabilityAnalysis.objects.scope(dataspace).get_or_none( + product_package=product_package, + vulnerability=vulnerability, + ) + if existing: + return JsonResponse( + {"error": "An analysis already exists for this vulnerability."}, status=400 + ) + + analysis = VulnerabilityAnalysis( + product_package=product_package, + vulnerability=vulnerability, + dataspace=dataspace, + ) + preset.apply_to_analysis(analysis) + + content_fields = [analysis.state, analysis.justification, analysis.responses, analysis.detail] + if not any(content_fields): + return JsonResponse({"error": "This preset has no content fields to apply."}, status=400) + + analysis.applied_by_preset = preset + analysis.save() + + return JsonResponse({"success": "applied"}, status=200) + + @login_required @csrf_exempt @require_http_methods(["DELETE"]) From ff79d751a2ccc82335306748a85e2dcf9df558c3 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 14 Aug 2026 13:52:07 +0200 Subject: [PATCH 60/90] add unit test coverage Signed-off-by: tdruez --- dje/tests/test_forms.py | 2 +- product_portfolio/tests/test_views.py | 15 + vulnerabilities/triage/tests/__init__.py | 59 +++ vulnerabilities/triage/tests/test_engine.py | 380 +++++++++++++++++++ vulnerabilities/triage/tests/test_models.py | 236 ++++++++++++ vulnerabilities/triage/tests/test_rules.py | 283 ++++++++++++++ vulnerabilities/triage/tests/test_signals.py | 247 ++++++++++++ vulnerabilities/triage/tests/test_tasks.py | 72 ++++ 8 files changed, 1293 insertions(+), 1 deletion(-) create mode 100644 vulnerabilities/triage/tests/__init__.py create mode 100644 vulnerabilities/triage/tests/test_engine.py create mode 100644 vulnerabilities/triage/tests/test_models.py create mode 100644 vulnerabilities/triage/tests/test_rules.py create mode 100644 vulnerabilities/triage/tests/test_signals.py create mode 100644 vulnerabilities/triage/tests/test_tasks.py diff --git a/dje/tests/test_forms.py b/dje/tests/test_forms.py index ed21e465..2bd70bdd 100644 --- a/dje/tests/test_forms.py +++ b/dje/tests/test_forms.py @@ -110,7 +110,7 @@ def test_tabs_permission_formset_load_perms(self): def test_copy_defaults_form_get_all_dataspaced_models(self): dataspaced_models = CopyDefaultsForm.get_all_dataspaced_models() - self.assertEqual(10, len(dataspaced_models)) + self.assertEqual(11, len(dataspaced_models)) self.assertEqual(7, len(dataspaced_models.get("Component Catalog"))) self.assertIn("Subcomponent", str(dataspaced_models.get("Component Catalog"))) diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 24c2abeb..ba7d6732 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -64,6 +64,9 @@ from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.tests import make_vulnerability from vulnerabilities.tests import make_vulnerability_analysis +from vulnerabilities.triage.models import ProductTriageRuleset +from vulnerabilities.triage.models import TriageAction +from vulnerabilities.triage.models import TriageRuleset from workflow.models import Request from workflow.models import RequestTemplate @@ -298,6 +301,18 @@ def test_product_portfolio_detail_view_tab_vulnerability_queryset(self): def test_product_portfolio_tab_vulnerability_view_filters(self): self.client.login(username="nexb_user", password="secret") + # The "Recommendation" column, and its triage_action filter, only render when the + # product has at least one enabled TriageRuleset assigned to it. + ruleset = TriageRuleset.objects.create( + name="Upgrade Ruleset", + action=TriageAction.UPGRADE, + precedence=100, + dataspace=self.dataspace, + ) + ProductTriageRuleset.objects.create( + product=self.product1, ruleset=ruleset, dataspace=self.dataspace + ) + url = self.product1.get_url("tab_vulnerabilities") response = self.client.get(url) self.assertContains(response, "?vulnerabilities-triage_action=#vulnerabilities") diff --git a/vulnerabilities/triage/tests/__init__.py b/vulnerabilities/triage/tests/__init__.py new file mode 100644 index 00000000..71589a3f --- /dev/null +++ b/vulnerabilities/triage/tests/__init__.py @@ -0,0 +1,59 @@ +# +# 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 random + +from dje.tests import make_string +from vulnerabilities.triage.models import AnalysisPreset +from vulnerabilities.triage.models import ProductTriageRuleset +from vulnerabilities.triage.models import TriageRuleset + + +def make_triage_ruleset(dataspace, **data): + """Create a TriageRuleset for test purposes.""" + if "name" not in data: + data["name"] = f"ruleset-{make_string(10)}" + + if "precedence" not in data: + # `precedence` is unique per dataspace: randomize the default so tests creating + # several rulesets without an explicit precedence do not collide with each other. + data["precedence"] = random.randint(1, 1_000_000) + + return TriageRuleset.objects.create( + dataspace=dataspace, + **data, + ) + + +def make_analysis_preset(dataspace, **data): + """Create an AnalysisPreset for test purposes.""" + if "name" not in data: + data["name"] = f"preset-{make_string(10)}" + + if not any(data.get(field) for field in ("state", "justification", "responses", "detail")): + data["state"] = AnalysisPreset.State.NOT_AFFECTED + + return AnalysisPreset.objects.create( + dataspace=dataspace, + **data, + ) + + +def make_product_triage_ruleset(product, ruleset=None, **data): + """Activate a TriageRuleset for the given product.""" + dataspace = product.dataspace + + if not ruleset: + ruleset = make_triage_ruleset(dataspace) + + return ProductTriageRuleset.objects.create( + product=product, + ruleset=ruleset, + dataspace=dataspace, + **data, + ) diff --git a/vulnerabilities/triage/tests/test_engine.py b/vulnerabilities/triage/tests/test_engine.py new file mode 100644 index 00000000..552c170f --- /dev/null +++ b/vulnerabilities/triage/tests/test_engine.py @@ -0,0 +1,380 @@ +# +# 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.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from component_catalog.tests import make_package +from dje.models import Dataspace +from dje.tests import create_user +from product_portfolio.models import Product +from product_portfolio.tests import make_product +from product_portfolio.tests import make_product_package +from vulnerabilities.models import VulnerabilityAnalysis +from vulnerabilities.tests import make_vulnerability +from vulnerabilities.tests import make_vulnerability_analysis +from vulnerabilities.triage.engine import apply_preset_for_vulnerabilities +from vulnerabilities.triage.engine import collect_matches +from vulnerabilities.triage.engine import create_triage_requests +from vulnerabilities.triage.engine import delete_preset_analyses_for_product +from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.engine import sync_triage_records +from vulnerabilities.triage.models import AnalysisPreset +from vulnerabilities.triage.models import TriageAction +from vulnerabilities.triage.models import TriageRecord +from vulnerabilities.triage.tests import make_analysis_preset +from vulnerabilities.triage.tests import make_triage_ruleset +from workflow.models import Request +from workflow.models import RequestTemplate + + +class CollectMatchesTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + make_product_package(self.product, package=self.package) + + def test_collects_vulnerabilities_matching_an_active_rule(self): + vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + matches = collect_matches(ruleset, self.product) + self.assertEqual({vulnerability.pk: ["risk_score"]}, matches) + + def test_skips_inactive_rule(self): + make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, + rules_config={"risk_score": {"is_active": False, "min_risk_score": 8.0}}, + ) + matches = collect_matches(ruleset, self.product) + self.assertEqual({}, matches) + + def test_skips_unknown_rule_type(self): + make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, + rules_config={"not_a_real_rule": {"is_active": True}}, + ) + matches = collect_matches(ruleset, self.product) + self.assertEqual({}, matches) + + def test_lists_every_rule_type_that_matches_the_same_vulnerability(self): + vulnerability = make_vulnerability( + self.dataspace, affecting=self.package, risk_score=9.0, exploitability=2.0 + ) + ruleset = make_triage_ruleset( + self.dataspace, + rules_config={ + "risk_score": {"is_active": True, "min_risk_score": 8.0}, + "exploited_vulnerability": {"is_active": True}, + }, + ) + matches = collect_matches(ruleset, self.product) + self.assertEqual({"risk_score", "exploited_vulnerability"}, set(matches[vulnerability.pk])) + + def test_passes_rule_specific_parameters_to_the_handler(self): + vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=5.0) + ruleset = make_triage_ruleset( + self.dataspace, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 5.0}}, + ) + matches = collect_matches(ruleset, self.product) + self.assertEqual({vulnerability.pk: ["risk_score"]}, matches) + + +class ApplyPresetForVulnerabilitiesTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + self.product_package = make_product_package(self.product, package=self.package) + self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package) + + def test_creates_a_new_analysis_from_the_preset(self): + preset = make_analysis_preset( + self.dataspace, + state=AnalysisPreset.State.NOT_AFFECTED, + detail="Not deployed", + ) + apply_preset_for_vulnerabilities(preset, self.product, [self.vulnerability.pk]) + analysis = VulnerabilityAnalysis.objects.get( + product_package=self.product_package, vulnerability=self.vulnerability + ) + self.assertEqual(AnalysisPreset.State.NOT_AFFECTED, analysis.state) + self.assertEqual(preset, analysis.applied_by_preset) + + def test_does_not_overwrite_a_human_owned_analysis(self): + make_vulnerability_analysis(self.product_package, self.vulnerability, state="exploitable") + preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) + apply_preset_for_vulnerabilities(preset, self.product, [self.vulnerability.pk]) + analysis = VulnerabilityAnalysis.objects.get( + product_package=self.product_package, vulnerability=self.vulnerability + ) + self.assertEqual("exploitable", analysis.state) + self.assertIsNone(analysis.applied_by_preset) + + def test_updates_an_existing_preset_owned_analysis(self): + first_preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.IN_TRIAGE) + apply_preset_for_vulnerabilities(first_preset, self.product, [self.vulnerability.pk]) + + second_preset = make_analysis_preset( + self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED + ) + apply_preset_for_vulnerabilities(second_preset, self.product, [self.vulnerability.pk]) + + analysis = VulnerabilityAnalysis.objects.get( + product_package=self.product_package, vulnerability=self.vulnerability + ) + self.assertEqual(AnalysisPreset.State.NOT_AFFECTED, analysis.state) + self.assertEqual(second_preset, analysis.applied_by_preset) + self.assertEqual(1, VulnerabilityAnalysis.objects.count()) + + def test_skips_creation_when_the_preset_has_no_content_field_set(self): + # An AnalysisPreset always requires at least one content field to be saved (see + # VulnerabilityAnalysisContentMixin.save), so this can only happen with an in-memory + # preset. This exercises the defensive guard against saving a content-less analysis. + content_less_preset = AnalysisPreset(dataspace=self.dataspace, is_reachable=True) + apply_preset_for_vulnerabilities(content_less_preset, self.product, [self.vulnerability.pk]) + self.assertFalse(VulnerabilityAnalysis.objects.exists()) + + def test_does_nothing_when_no_product_package_carries_the_vulnerability(self): + other_package = make_package(self.dataspace) + other_vulnerability = make_vulnerability(self.dataspace, affecting=other_package) + preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) + apply_preset_for_vulnerabilities(preset, self.product, [other_vulnerability.pk]) + self.assertFalse(VulnerabilityAnalysis.objects.exists()) + + +class DeletePresetAnalysesForProductTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + self.product_package = make_product_package(self.product, package=self.package) + self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package) + self.preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.IN_TRIAGE) + + def test_deletes_the_matching_preset_owned_analysis(self): + apply_preset_for_vulnerabilities(self.preset, self.product, [self.vulnerability.pk]) + delete_preset_analyses_for_product(self.preset.pk, self.product, [self.vulnerability.pk]) + self.assertFalse(VulnerabilityAnalysis.objects.exists()) + + def test_does_not_delete_analyses_owned_by_a_different_preset(self): + other_preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.EXPLOITABLE) + apply_preset_for_vulnerabilities(other_preset, self.product, [self.vulnerability.pk]) + delete_preset_analyses_for_product(self.preset.pk, self.product, [self.vulnerability.pk]) + self.assertTrue(VulnerabilityAnalysis.objects.exists()) + + def test_does_not_delete_analyses_from_another_product(self): + other_product = make_product(self.dataspace) + other_product_package = make_product_package(other_product, package=self.package) + make_vulnerability_analysis( + other_product_package, + self.vulnerability, + state="in_triage", + applied_by_preset=self.preset, + ) + delete_preset_analyses_for_product(self.preset.pk, self.product, [self.vulnerability.pk]) + self.assertTrue(VulnerabilityAnalysis.objects.exists()) + + +class SyncTriageRecordsTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + self.product_package = make_product_package(self.product, package=self.package) + self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package) + self.ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.UPGRADE) + + def test_creates_one_record_per_matching_vulnerability(self): + sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) + record = TriageRecord.objects.get() + self.assertEqual(self.vulnerability, record.vulnerability) + self.assertEqual(self.product, record.product) + self.assertEqual(self.ruleset, record.ruleset) + self.assertEqual(TriageAction.UPGRADE, record.action) + self.assertEqual(["risk_score"], record.matched_rules) + + def test_reevaluation_updates_matched_rules_without_duplicating_the_record(self): + sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) + first_detected_date = TriageRecord.objects.get().detected_date + + sync_triage_records( + self.ruleset, + self.product, + {self.vulnerability.pk: ["risk_score", "exploited_vulnerability"]}, + ) + + self.assertEqual(1, TriageRecord.objects.count()) + record = TriageRecord.objects.get() + self.assertEqual(["risk_score", "exploited_vulnerability"], record.matched_rules) + self.assertEqual(first_detected_date, record.detected_date) + + def test_deletes_records_for_vulnerabilities_that_no_longer_match(self): + sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) + sync_triage_records(self.ruleset, self.product, {}) + self.assertFalse(TriageRecord.objects.exists()) + + def test_does_not_touch_records_from_another_ruleset(self): + other_ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.NOTIFY) + sync_triage_records(other_ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) + sync_triage_records(self.ruleset, self.product, {}) + self.assertEqual(1, TriageRecord.objects.count()) + self.assertEqual(other_ruleset, TriageRecord.objects.get().ruleset) + + def test_applies_preset_for_matching_vulnerabilities_by_default(self): + preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) + self.ruleset.analysis_preset = preset + self.ruleset.save() + sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) + analysis = VulnerabilityAnalysis.objects.get() + self.assertEqual(preset, analysis.applied_by_preset) + + def test_apply_preset_false_skips_preset_application(self): + preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) + self.ruleset.analysis_preset = preset + self.ruleset.save() + sync_triage_records( + self.ruleset, + self.product, + {self.vulnerability.pk: ["risk_score"]}, + apply_preset=False, + ) + self.assertFalse(VulnerabilityAnalysis.objects.exists()) + + def test_deletes_the_preset_analysis_of_a_now_stale_record(self): + preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) + self.ruleset.analysis_preset = preset + self.ruleset.save() + sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) + self.assertTrue(VulnerabilityAnalysis.objects.exists()) + + sync_triage_records(self.ruleset, self.product, {}) + + self.assertFalse(VulnerabilityAnalysis.objects.exists()) + + def test_opens_a_request_for_a_new_record_when_request_template_is_set(self): + requester = create_user("requester", self.dataspace) + request_template = RequestTemplate.objects.create( + name="Vulnerability Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=requester, + ) + self.ruleset.request_template = request_template + self.ruleset.save() + + sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) + + record = TriageRecord.objects.get() + self.assertIsNotNone(record.request) + self.assertEqual(1, Request.objects.count()) + + def test_does_not_reopen_a_request_for_a_record_that_already_has_one(self): + requester = create_user("requester", self.dataspace) + request_template = RequestTemplate.objects.create( + name="Vulnerability Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=requester, + ) + self.ruleset.request_template = request_template + self.ruleset.save() + + sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) + sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) + + self.assertEqual(1, Request.objects.count()) + + +class CreateTriageRequestsTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + make_product_package(self.product, package=self.package) + self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package) + self.ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.UPGRADE) + self.requester = create_user("requester", self.dataspace) + self.request_template = RequestTemplate.objects.create( + name="Vulnerability Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=self.requester, + ) + + def test_opens_one_request_per_record_using_the_template_creator_as_requester(self): + record = TriageRecord.objects.create( + vulnerability=self.vulnerability, + product=self.product, + ruleset=self.ruleset, + action=self.ruleset.action, + dataspace=self.dataspace, + ) + create_triage_requests(self.request_template, self.product, [record]) + + request = Request.objects.get() + self.assertEqual(self.requester, request.requester) + self.assertEqual(self.product, request.product_context) + self.assertIn(self.vulnerability.advisory_id, request.title) + + def test_sets_the_request_on_the_triage_record(self): + record = TriageRecord.objects.create( + vulnerability=self.vulnerability, + product=self.product, + ruleset=self.ruleset, + action=self.ruleset.action, + dataspace=self.dataspace, + ) + create_triage_requests(self.request_template, self.product, [record]) + record.refresh_from_db() + self.assertEqual(Request.objects.get(), record.request) + + +class EvaluateRulesetTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + make_product_package(self.product, package=self.package) + + def test_creates_a_triage_record_end_to_end_for_a_matching_vulnerability(self): + vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, + action=TriageAction.UPGRADE, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + evaluate_ruleset(ruleset, self.product) + record = TriageRecord.objects.get() + self.assertEqual(vulnerability, record.vulnerability) + self.assertEqual(["risk_score"], record.matched_rules) + + def test_removes_the_record_once_the_vulnerability_no_longer_matches(self): + vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, + action=TriageAction.UPGRADE, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + evaluate_ruleset(ruleset, self.product) + self.assertTrue(TriageRecord.objects.exists()) + + vulnerability.risk_score = 2.0 + vulnerability.save() + evaluate_ruleset(ruleset, self.product) + + self.assertFalse(TriageRecord.objects.exists()) diff --git a/vulnerabilities/triage/tests/test_models.py b/vulnerabilities/triage/tests/test_models.py new file mode 100644 index 00000000..331af4cb --- /dev/null +++ b/vulnerabilities/triage/tests/test_models.py @@ -0,0 +1,236 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from django.test import TestCase + +from component_catalog.tests import make_package +from dje.models import Dataspace +from product_portfolio.tests import make_product +from product_portfolio.tests import make_product_package +from vulnerabilities.models import VulnerabilityAnalysis +from vulnerabilities.tests import make_vulnerability +from vulnerabilities.triage.models import AnalysisPreset +from vulnerabilities.triage.models import ProductTriageRuleset +from vulnerabilities.triage.models import TriageAction +from vulnerabilities.triage.models import TriageRecord +from vulnerabilities.triage.models import TriageRuleset +from vulnerabilities.triage.tests import make_analysis_preset +from vulnerabilities.triage.tests import make_product_triage_ruleset +from vulnerabilities.triage.tests import make_triage_ruleset + + +class AnalysisPresetModelTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + + def test_save_requires_at_least_one_content_field(self): + # AnalysisPreset shares its `save` validation with VulnerabilityAnalysis through + # VulnerabilityAnalysisContentMixin: a preset that only sets `is_reachable` has no + # content to apply and must be rejected the same way a bare analysis would be. + preset = AnalysisPreset(dataspace=self.dataspace, name="No content", is_reachable=True) + with self.assertRaises(ValueError): + preset.save() + + def test_save_accepts_detail_only(self): + preset = AnalysisPreset(dataspace=self.dataspace, name="Detail only", detail="Some detail") + preset.save() + self.assertIsNotNone(preset.pk) + + def test_apply_to_analysis_copies_non_blank_fields_only(self): + preset = make_analysis_preset( + self.dataspace, + state=AnalysisPreset.State.NOT_AFFECTED, + justification="", + detail="Not deployed", + ) + package = make_package(self.dataspace) + product = make_product(self.dataspace) + product_package = make_product_package(product, package=package) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + analysis = VulnerabilityAnalysis( + product_package=product_package, + vulnerability=vulnerability, + dataspace=self.dataspace, + justification=VulnerabilityAnalysis.Justification.CODE_NOT_REACHABLE, + ) + + preset.apply_to_analysis(analysis) + + self.assertEqual(AnalysisPreset.State.NOT_AFFECTED, analysis.state) + self.assertEqual("Not deployed", analysis.detail) + # Blank preset field must not overwrite the pre-existing value on the analysis. + self.assertEqual( + VulnerabilityAnalysis.Justification.CODE_NOT_REACHABLE, analysis.justification + ) + + def test_apply_to_analysis_leaves_is_reachable_untouched_when_preset_value_is_none(self): + preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.IN_TRIAGE) + self.assertIsNone(preset.is_reachable) + package = make_package(self.dataspace) + product = make_product(self.dataspace) + product_package = make_product_package(product, package=package) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + analysis = VulnerabilityAnalysis( + product_package=product_package, + vulnerability=vulnerability, + dataspace=self.dataspace, + is_reachable=True, + ) + + preset.apply_to_analysis(analysis) + + self.assertTrue(analysis.is_reachable) + + def test_apply_to_analysis_sets_is_reachable_when_preset_value_is_false(self): + preset = make_analysis_preset( + self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED, is_reachable=False + ) + package = make_package(self.dataspace) + product = make_product(self.dataspace) + product_package = make_product_package(product, package=package) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + analysis = VulnerabilityAnalysis( + product_package=product_package, + vulnerability=vulnerability, + dataspace=self.dataspace, + is_reachable=True, + ) + + preset.apply_to_analysis(analysis) + + self.assertFalse(analysis.is_reachable) + + +class TriageRulesetModelTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + + def test_str(self): + ruleset = make_triage_ruleset(self.dataspace, name="My Ruleset") + self.assertEqual("My Ruleset", str(ruleset)) + + def test_default_ordering_is_by_descending_precedence_then_name(self): + low = make_triage_ruleset(self.dataspace, name="B", precedence=100) + high = make_triage_ruleset(self.dataspace, name="A", precedence=200) + self.assertEqual([high, low], list(TriageRuleset.objects.all())) + + def test_precedence_is_unique_per_dataspace(self): + make_triage_ruleset(self.dataspace, precedence=500) + with self.assertRaises(Exception): + make_triage_ruleset(self.dataspace, precedence=500) + + +class ProductTriageRulesetModelTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.ruleset = make_triage_ruleset(self.dataspace) + + def test_str(self): + assignment = make_product_triage_ruleset(self.product, ruleset=self.ruleset) + self.assertEqual(f"{self.product} / {self.ruleset}", str(assignment)) + + def test_product_ruleset_pair_is_unique(self): + make_product_triage_ruleset(self.product, ruleset=self.ruleset) + with self.assertRaises(Exception): + ProductTriageRuleset.objects.create( + product=self.product, ruleset=self.ruleset, dataspace=self.dataspace + ) + + +class TriageRecordModelTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + make_product_package(self.product, package=self.package) + self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package) + self.ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.NOTIFY) + + def test_str(self): + record = TriageRecord.objects.create( + vulnerability=self.vulnerability, + product=self.product, + ruleset=self.ruleset, + action=self.ruleset.action, + dataspace=self.dataspace, + ) + expected = f"{self.vulnerability} / {self.product} / {self.ruleset}: {self.ruleset.action}" + self.assertEqual(expected, str(record)) + + +class TriageRecordQuerySetHighestPrecedenceTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + make_product_package(self.product, package=self.package) + self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package) + + def _make_record(self, ruleset): + return TriageRecord.objects.create( + vulnerability=self.vulnerability, + product=self.product, + ruleset=ruleset, + action=ruleset.action, + dataspace=self.dataspace, + ) + + def test_returns_the_record_of_the_highest_precedence_assigned_ruleset(self): + low_ruleset = make_triage_ruleset( + self.dataspace, precedence=100, action=TriageAction.NOTIFY + ) + high_ruleset = make_triage_ruleset( + self.dataspace, precedence=900, action=TriageAction.UPGRADE + ) + make_product_triage_ruleset(self.product, ruleset=low_ruleset) + make_product_triage_ruleset(self.product, ruleset=high_ruleset) + self._make_record(low_ruleset) + high_record = self._make_record(high_ruleset) + + winning_records = TriageRecord.objects.highest_precedence() + + self.assertEqual([high_record], list(winning_records)) + + def test_excludes_records_for_a_ruleset_not_assigned_to_the_product(self): + assigned_ruleset = make_triage_ruleset(self.dataspace, precedence=100) + unassigned_ruleset = make_triage_ruleset(self.dataspace, precedence=900) + make_product_triage_ruleset(self.product, ruleset=assigned_ruleset) + assigned_record = self._make_record(assigned_ruleset) + self._make_record(unassigned_ruleset) + + winning_records = TriageRecord.objects.highest_precedence() + + self.assertEqual([assigned_record], list(winning_records)) + + def test_excludes_records_for_a_disabled_ruleset(self): + ruleset = make_triage_ruleset(self.dataspace, enabled=False) + make_product_triage_ruleset(self.product, ruleset=ruleset) + self._make_record(ruleset) + + self.assertEqual(0, TriageRecord.objects.highest_precedence().count()) + + def test_evaluates_precedence_independently_per_product(self): + other_product = make_product(self.dataspace) + make_product_package(other_product, package=self.package) + low_ruleset = make_triage_ruleset(self.dataspace, precedence=100) + high_ruleset = make_triage_ruleset(self.dataspace, precedence=900) + make_product_triage_ruleset(self.product, ruleset=low_ruleset) + make_product_triage_ruleset(other_product, ruleset=high_ruleset) + low_record = self._make_record(low_ruleset) + other_record = TriageRecord.objects.create( + vulnerability=self.vulnerability, + product=other_product, + ruleset=high_ruleset, + action=high_ruleset.action, + dataspace=self.dataspace, + ) + + winning_records = TriageRecord.objects.highest_precedence() + + self.assertEqual({low_record, other_record}, set(winning_records)) diff --git a/vulnerabilities/triage/tests/test_rules.py b/vulnerabilities/triage/tests/test_rules.py new file mode 100644 index 00000000..e0de35ec --- /dev/null +++ b/vulnerabilities/triage/tests/test_rules.py @@ -0,0 +1,283 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from component_catalog.models import PackageAffectedByVulnerability +from component_catalog.tests import make_package +from dje.models import Dataspace +from product_portfolio.tests import make_product +from product_portfolio.tests import make_product_package +from vulnerabilities.tests import make_vulnerability +from vulnerabilities.tests import make_vulnerability_analysis +from vulnerabilities.triage.rules import DevOnlyPackageTriageRule +from vulnerabilities.triage.rules import ExploitedVulnerabilityTriageRule +from vulnerabilities.triage.rules import ReachableVulnerabilityTriageRule +from vulnerabilities.triage.rules import RiskScoreTriageRule +from vulnerabilities.triage.rules import StaleVulnerabilityTriageRule +from vulnerabilities.triage.rules import UnresolvedVulnerabilityTriageRule +from vulnerabilities.triage.rules import WeightedRiskTriageRule +from vulnerabilities.triage.rules import rule_parameters_from_config + + +class RiskScoreTriageRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def test_matches_vulnerability_at_or_above_default_threshold(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=8.0) + make_product_package(self.product, package=package) + matches = RiskScoreTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([vulnerability], list(matches)) + + def test_excludes_vulnerability_below_default_threshold(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package, risk_score=7.9) + make_product_package(self.product, package=package) + matches = RiskScoreTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_custom_min_risk_score_parameter(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=5.0) + make_product_package(self.product, package=package) + matches = RiskScoreTriageRule().get_matching_vulnerabilities( + self.product, parameters={"min_risk_score": 5.0} + ) + self.assertEqual([vulnerability], list(matches)) + + def test_ignores_vulnerabilities_affecting_packages_outside_the_product(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + matches = RiskScoreTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + +class WeightedRiskTriageRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def test_matches_package_at_or_above_default_threshold(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=8.0) + make_product_package(self.product, package=package) + matches = WeightedRiskTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([vulnerability], list(matches)) + + def test_excludes_package_below_default_threshold(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package, risk_score=7.9) + make_product_package(self.product, package=package) + matches = WeightedRiskTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_custom_min_weighted_risk_score_parameter(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=4.0) + make_product_package(self.product, package=package) + matches = WeightedRiskTriageRule().get_matching_vulnerabilities( + self.product, parameters={"min_weighted_risk_score": 4.0} + ) + self.assertEqual([vulnerability], list(matches)) + + +class ExploitedVulnerabilityTriageRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def test_matches_vulnerability_with_known_exploits(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, exploitability=2.0) + make_product_package(self.product, package=package) + matches = ExploitedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([vulnerability], list(matches)) + + def test_excludes_vulnerability_with_potential_exploits_only(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package, exploitability=1.0) + make_product_package(self.product, package=package) + matches = ExploitedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_excludes_vulnerability_with_no_exploitability_set(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package) + make_product_package(self.product, package=package) + matches = ExploitedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + +class ReachableVulnerabilityTriageRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def test_matches_vulnerability_confirmed_reachable(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + product_package = make_product_package(self.product, package=package) + make_vulnerability_analysis(product_package, vulnerability, is_reachable=True) + matches = ReachableVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([vulnerability], list(matches)) + + def test_excludes_vulnerability_marked_not_reachable(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + product_package = make_product_package(self.product, package=package) + make_vulnerability_analysis(product_package, vulnerability, is_reachable=False) + matches = ReachableVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_excludes_vulnerability_with_no_analysis(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package) + make_product_package(self.product, package=package) + matches = ReachableVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + +class UnresolvedVulnerabilityTriageRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def test_matches_vulnerability_with_no_terminal_analysis(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + make_product_package(self.product, package=package) + matches = UnresolvedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([vulnerability], list(matches)) + + def test_excludes_vulnerability_with_resolved_analysis(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + product_package = make_product_package(self.product, package=package) + make_vulnerability_analysis(product_package, vulnerability, state="resolved") + matches = UnresolvedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_excludes_vulnerability_with_not_affected_analysis(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + product_package = make_product_package(self.product, package=package) + make_vulnerability_analysis(product_package, vulnerability, state="not_affected") + matches = UnresolvedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_matches_when_at_least_one_package_still_unresolved(self): + # Two packages in the product carry the same vulnerability: one is resolved, the + # other isn't. The vulnerability is still considered unresolved for the product. + package1 = make_package(self.dataspace) + package2 = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=[package1, package2]) + product_package1 = make_product_package(self.product, package=package1) + make_product_package(self.product, package=package2) + make_vulnerability_analysis(product_package1, vulnerability, state="resolved") + matches = UnresolvedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([vulnerability], list(matches)) + + +class StaleVulnerabilityTriageRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def _age_detection(self, package, vulnerability, days): + old_date = timezone.now() - timedelta(days=days) + PackageAffectedByVulnerability.objects.filter( + package=package, vulnerability=vulnerability + ).update(detected_date=old_date) + + def test_matches_old_high_risk_unresolved_vulnerability(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + make_product_package(self.product, package=package) + self._age_detection(package, vulnerability, days=60) + matches = StaleVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([vulnerability], list(matches)) + + def test_excludes_recent_vulnerability(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + make_product_package(self.product, package=package) + matches = StaleVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_excludes_vulnerability_below_min_risk_score(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=3.0) + make_product_package(self.product, package=package) + self._age_detection(package, vulnerability, days=60) + matches = StaleVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_excludes_vulnerability_with_terminal_analysis(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + product_package = make_product_package(self.product, package=package) + self._age_detection(package, vulnerability, days=60) + make_vulnerability_analysis(product_package, vulnerability, state="resolved") + matches = StaleVulnerabilityTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_custom_max_days_triggers_for_links_within_window(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + make_product_package(self.product, package=package) + self._age_detection(package, vulnerability, days=10) + matches = StaleVulnerabilityTriageRule().get_matching_vulnerabilities( + self.product, parameters={"max_days": 5} + ) + self.assertEqual([vulnerability], list(matches)) + + +class DevOnlyPackageTriageRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + def test_matches_vulnerability_affecting_only_a_non_deployed_package(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + make_product_package(self.product, package=package, is_deployed=False) + matches = DevOnlyPackageTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([vulnerability], list(matches)) + + def test_excludes_vulnerability_affecting_a_deployed_package(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package) + make_product_package(self.product, package=package, is_deployed=True) + matches = DevOnlyPackageTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_excludes_vulnerability_when_also_carried_by_a_deployed_package(self): + # Same vulnerability reaches the product through both a deployed and a non-deployed + # package: it is still live in production, so it must not be flagged as dev-only. + dev_package = make_package(self.dataspace) + deployed_package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=[dev_package, deployed_package]) + make_product_package(self.product, package=dev_package, is_deployed=False) + make_product_package(self.product, package=deployed_package, is_deployed=True) + matches = DevOnlyPackageTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + +class RuleParametersFromConfigTestCase(TestCase): + def test_excludes_is_active_key(self): + config = {"is_active": True, "min_risk_score": 7.0} + self.assertEqual({"min_risk_score": 7.0}, rule_parameters_from_config(config)) + + def test_returns_empty_dict_when_only_is_active_present(self): + self.assertEqual({}, rule_parameters_from_config({"is_active": True})) diff --git a/vulnerabilities/triage/tests/test_signals.py b/vulnerabilities/triage/tests/test_signals.py new file mode 100644 index 00000000..6a204c5f --- /dev/null +++ b/vulnerabilities/triage/tests/test_signals.py @@ -0,0 +1,247 @@ +# +# 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 unittest.mock import patch + +from django.test import TestCase + +from component_catalog.tests import make_package +from dje.models import Dataspace +from product_portfolio.tests import make_product +from product_portfolio.tests import make_product_package +from vulnerabilities.models import VulnerabilityAnalysis +from vulnerabilities.tests import make_vulnerability +from vulnerabilities.tests import make_vulnerability_analysis +from vulnerabilities.triage.engine import apply_preset_for_vulnerabilities +from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.models import AnalysisPreset +from vulnerabilities.triage.models import TriageAction +from vulnerabilities.triage.models import TriageRecord +from vulnerabilities.triage.signals import reevaluate_product_rulesets +from vulnerabilities.triage.tests import make_analysis_preset +from vulnerabilities.triage.tests import make_product_triage_ruleset +from vulnerabilities.triage.tests import make_triage_ruleset + + +class ReevaluateProductRulesetsTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + @patch("vulnerabilities.triage.signals.evaluate_ruleset") + def test_evaluates_every_enabled_ruleset_assigned_to_the_product(self, mock_evaluate): + ruleset = make_triage_ruleset(self.dataspace, enabled=True) + make_product_triage_ruleset(self.product, ruleset=ruleset) + + reevaluate_product_rulesets(self.product) + + mock_evaluate.assert_called_once_with( + ruleset=ruleset, product=self.product, apply_preset=True + ) + + @patch("vulnerabilities.triage.signals.evaluate_ruleset") + def test_skips_disabled_ruleset_assignments(self, mock_evaluate): + ruleset = make_triage_ruleset(self.dataspace, enabled=False) + make_product_triage_ruleset(self.product, ruleset=ruleset) + + reevaluate_product_rulesets(self.product) + + mock_evaluate.assert_not_called() + + @patch("vulnerabilities.triage.signals.evaluate_ruleset") + def test_apply_preset_flag_is_forwarded(self, mock_evaluate): + ruleset = make_triage_ruleset(self.dataspace, enabled=True) + make_product_triage_ruleset(self.product, ruleset=ruleset) + + reevaluate_product_rulesets(self.product, apply_preset=False) + + mock_evaluate.assert_called_once_with( + ruleset=ruleset, product=self.product, apply_preset=False + ) + + +class TriageRulesetSaveSignalTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + @patch("vulnerabilities.triage.signals.evaluate_ruleset") + def test_creating_a_ruleset_does_not_trigger_evaluation(self, mock_evaluate): + make_triage_ruleset(self.dataspace) + mock_evaluate.assert_not_called() + + @patch("vulnerabilities.triage.signals.evaluate_ruleset") + def test_updating_an_enabled_assigned_ruleset_reevaluates_its_products(self, mock_evaluate): + ruleset = make_triage_ruleset(self.dataspace) + make_product_triage_ruleset(self.product, ruleset=ruleset) + mock_evaluate.reset_mock() + + ruleset.description = "updated" + ruleset.save() + + mock_evaluate.assert_called_once_with(ruleset=ruleset, product=self.product) + + @patch("vulnerabilities.triage.signals.evaluate_ruleset") + def test_updating_an_unassigned_ruleset_does_not_trigger_evaluation(self, mock_evaluate): + ruleset = make_triage_ruleset(self.dataspace) + mock_evaluate.reset_mock() + + ruleset.description = "updated" + ruleset.save() + + mock_evaluate.assert_not_called() + + @patch("vulnerabilities.triage.signals.evaluate_ruleset") + def test_disabling_a_ruleset_deletes_its_triage_records_instead_of_evaluating( + self, mock_evaluate + ): + package = make_package(self.dataspace) + make_product_package(self.product, package=package) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.NOTIFY) + make_product_triage_ruleset(self.product, ruleset=ruleset) + TriageRecord.objects.create( + vulnerability=vulnerability, + product=self.product, + ruleset=ruleset, + action=ruleset.action, + dataspace=self.dataspace, + ) + mock_evaluate.reset_mock() + + ruleset.enabled = False + ruleset.save() + + self.assertFalse(TriageRecord.objects.exists()) + mock_evaluate.assert_not_called() + + +class DeleteTriageRecordsOnUnassignSignalTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + self.product_package = make_product_package(self.product, package=self.package) + self.vulnerability = make_vulnerability( + self.dataspace, affecting=self.package, risk_score=9.0 + ) + self.ruleset = make_triage_ruleset( + self.dataspace, + action=TriageAction.NOTIFY, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + self.assignment = make_product_triage_ruleset(self.product, ruleset=self.ruleset) + evaluate_ruleset(self.ruleset, self.product) + self.assertTrue(TriageRecord.objects.exists()) + + def test_unassigning_the_ruleset_deletes_its_triage_records_for_the_product(self): + self.assignment.delete() + self.assertFalse(TriageRecord.objects.exists()) + + def test_does_not_delete_records_belonging_to_another_product(self): + other_product = make_product(self.dataspace) + make_product_package(other_product, package=self.package) + make_product_triage_ruleset(other_product, ruleset=self.ruleset) + evaluate_ruleset(self.ruleset, other_product) + other_record = TriageRecord.objects.get(product=other_product) + + self.assignment.delete() + + self.assertEqual([other_record], list(TriageRecord.objects.all())) + + def test_deletes_the_preset_analyses_tied_to_the_deleted_records(self): + preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) + self.ruleset.analysis_preset = preset + self.ruleset.save() # Re-evaluates the assigned product and auto-applies the preset. + self.assertTrue(VulnerabilityAnalysis.objects.exists()) + + self.assignment.delete() + + self.assertFalse(VulnerabilityAnalysis.objects.exists()) + + def test_does_not_delete_human_owned_analyses(self): + # A human analysis set before the preset exists is never auto-applied over, so it + # keeps applied_by_preset=None and must survive the preset cleanup below. + make_vulnerability_analysis(self.product_package, self.vulnerability, state="exploitable") + preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) + self.ruleset.analysis_preset = preset + self.ruleset.save() + + self.assignment.delete() + + analysis = VulnerabilityAnalysis.objects.get() + self.assertEqual("exploitable", analysis.state) + self.assertIsNone(analysis.applied_by_preset) + + +class ReevaluateOnAnalysisChangeSignalTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + self.product_package = make_product_package(self.product, package=self.package) + self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package) + + @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets") + def test_saving_a_human_owned_analysis_reevaluates_with_preset_application_enabled( + self, mock_reevaluate + ): + make_vulnerability_analysis(self.product_package, self.vulnerability, state="exploitable") + mock_reevaluate.assert_called_once_with(self.product, apply_preset=True) + + @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets") + def test_saving_a_preset_applied_analysis_does_not_reevaluate(self, mock_reevaluate): + preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) + apply_preset_for_vulnerabilities(preset, self.product, [self.vulnerability.pk]) + mock_reevaluate.assert_not_called() + + @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets") + def test_deleting_a_human_owned_analysis_reevaluates_with_preset_application_disabled( + self, mock_reevaluate + ): + analysis = make_vulnerability_analysis( + self.product_package, self.vulnerability, state="exploitable" + ) + mock_reevaluate.reset_mock() + + analysis.delete() + + mock_reevaluate.assert_called_once_with(self.product, apply_preset=False) + + @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets") + def test_deleting_a_preset_owned_analysis_reevaluates_with_preset_application_enabled( + self, mock_reevaluate + ): + preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) + apply_preset_for_vulnerabilities(preset, self.product, [self.vulnerability.pk]) + analysis = VulnerabilityAnalysis.objects.get() + mock_reevaluate.reset_mock() + + analysis.delete() + + mock_reevaluate.assert_called_once_with(self.product, apply_preset=True) + + +class ReevaluateOnProductPackageChangeSignalTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets") + def test_adding_a_product_package_reevaluates_the_product(self, mock_reevaluate): + make_product_package(self.product) + mock_reevaluate.assert_called_once_with(self.product) + + @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets") + def test_removing_a_product_package_reevaluates_the_product(self, mock_reevaluate): + product_package = make_product_package(self.product) + mock_reevaluate.reset_mock() + + product_package.delete() + + mock_reevaluate.assert_called_once_with(self.product) diff --git a/vulnerabilities/triage/tests/test_tasks.py b/vulnerabilities/triage/tests/test_tasks.py new file mode 100644 index 00000000..46b7f01a --- /dev/null +++ b/vulnerabilities/triage/tests/test_tasks.py @@ -0,0 +1,72 @@ +# +# 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 unittest.mock import patch + +from django.test import TestCase + +from dje.models import Dataspace +from product_portfolio.tests import make_product +from vulnerabilities.triage.tasks import evaluate_all_products_vulnerability_triage_task +from vulnerabilities.triage.tests import make_product_triage_ruleset +from vulnerabilities.triage.tests import make_triage_ruleset + + +class EvaluateAllProductsVulnerabilityTriageTaskTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + + @patch("vulnerabilities.triage.tasks.evaluate_ruleset") + def test_evaluates_every_assignment_of_an_enabled_ruleset(self, mock_evaluate): + product = make_product(self.dataspace) + ruleset = make_triage_ruleset(self.dataspace, enabled=True) + make_product_triage_ruleset(product, ruleset=ruleset) + + evaluate_all_products_vulnerability_triage_task() + + mock_evaluate.assert_called_once_with(ruleset=ruleset, product=product) + + @patch("vulnerabilities.triage.tasks.evaluate_ruleset") + def test_skips_assignments_of_a_disabled_ruleset(self, mock_evaluate): + product = make_product(self.dataspace) + ruleset = make_triage_ruleset(self.dataspace, enabled=False) + make_product_triage_ruleset(product, ruleset=ruleset) + + evaluate_all_products_vulnerability_triage_task() + + mock_evaluate.assert_not_called() + + @patch("vulnerabilities.triage.tasks.evaluate_ruleset") + def test_continues_evaluating_remaining_assignments_after_one_raises(self, mock_evaluate): + failing_product = make_product(self.dataspace, name="a-product") + ok_product = make_product(self.dataspace, name="b-product") + ruleset = make_triage_ruleset(self.dataspace) + make_product_triage_ruleset(failing_product, ruleset=ruleset) + make_product_triage_ruleset(ok_product, ruleset=ruleset) + mock_evaluate.side_effect = [Exception("boom"), None] + + with self.assertLogs("vulnerabilities.triage.tasks", level="ERROR") as captured: + evaluate_all_products_vulnerability_triage_task() + + self.assertEqual(2, mock_evaluate.call_count) + mock_evaluate.assert_any_call(ruleset=ruleset, product=failing_product) + mock_evaluate.assert_any_call(ruleset=ruleset, product=ok_product) + self.assertTrue(any("Triage evaluation failed" in line for line in captured.output)) + + @patch("vulnerabilities.triage.tasks.evaluate_ruleset") + def test_evaluates_assignments_across_multiple_products(self, mock_evaluate): + product1 = make_product(self.dataspace) + product2 = make_product(self.dataspace) + ruleset = make_triage_ruleset(self.dataspace) + make_product_triage_ruleset(product1, ruleset=ruleset) + make_product_triage_ruleset(product2, ruleset=ruleset) + + evaluate_all_products_vulnerability_triage_task() + + evaluated_products = [call.kwargs["product"] for call in mock_evaluate.call_args_list] + self.assertEqual({product1, product2}, set(evaluated_products)) From 112037b28eb209696f80097d062d516af4ea322a Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 14 Aug 2026 16:21:48 +0200 Subject: [PATCH 61/90] add tooltip on the apply present dropdown link Signed-off-by: tdruez --- .../tabs/tab_packages_vulnerabilities.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index 37f5a00e..a0f75266 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -146,8 +146,8 @@ {% if analysis_presets and not vulnerability.vulnerability_analysis %} - + {% endif %} {% if not forloop.first %}{% endif %} From 865df9eed12dcfb553e2d4ff5102074aa96ff4ba Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 14 Aug 2026 16:23:14 +0200 Subject: [PATCH 62/90] add missing unit test for full coverage Signed-off-by: tdruez --- product_portfolio/admin.py | 3 +- product_portfolio/tests/test_admin.py | 9 ++ product_portfolio/tests/test_filters.py | 45 +++++++ product_portfolio/tests/test_views.py | 135 +++++++++++++++++++++ vulnerabilities/tests/test_forms.py | 26 ++++ vulnerabilities/triage/tests/test_forms.py | 63 ++++++++++ 6 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 vulnerabilities/triage/tests/test_forms.py diff --git a/product_portfolio/admin.py b/product_portfolio/admin.py index 36ece772..ccb1789d 100644 --- a/product_portfolio/admin.py +++ b/product_portfolio/admin.py @@ -72,6 +72,7 @@ from product_portfolio.models import ProductRelationStatus from product_portfolio.models import ProductStatus from reporting.filters import ReportingQueryListFilter +from vulnerabilities.triage.models import ProductTriageRuleset @admin.register(ProductStatus, site=dejacode_site) @@ -240,7 +241,7 @@ def save_model(self, request, obj, form, change): if "_saveasnew" in request.POST: old_product_id = request.resolver_match.kwargs.get("object_id") old_product = self.get_object(request, old_product_id) - for model_class in [ProductComponent, ProductPackage]: + for model_class in [ProductComponent, ProductPackage, ProductTriageRuleset]: for relationship in model_class.objects.filter(product=old_product): relationship.id = None relationship.uuid = uuid.uuid4() diff --git a/product_portfolio/tests/test_admin.py b/product_portfolio/tests/test_admin.py index 32836d5c..1e015173 100644 --- a/product_portfolio/tests/test_admin.py +++ b/product_portfolio/tests/test_admin.py @@ -25,6 +25,8 @@ from product_portfolio.models import ProductComponent from product_portfolio.models import ProductPackage from product_portfolio.tests import make_product_dependency +from vulnerabilities.triage.models import ProductTriageRuleset +from vulnerabilities.triage.models import TriageRuleset class ProductPortfolioAdminsTestCase(TestCase): @@ -430,6 +432,12 @@ def test_product_admin_changeform_save_as_includes_relationships(self): ProductPackage.objects.create( product=self.product1, package=self.package1, dataspace=self.dataspace ) + ruleset = TriageRuleset.objects.create( + name="Upgrade Ruleset", precedence=100, dataspace=self.dataspace + ) + ProductTriageRuleset.objects.create( + product=self.product1, ruleset=ruleset, dataspace=self.dataspace + ) url = self.product1.get_admin_url() data = { @@ -447,6 +455,7 @@ def test_product_admin_changeform_save_as_includes_relationships(self): new_product = Product.unsecured_objects.get(name=self.product1.name, version="new version") self.assertEqual(1, new_product.productcomponents.count()) self.assertEqual(1, new_product.productpackages.count()) + self.assertEqual(1, new_product.product_triage_rulesets.count()) def test_codebaseresource_admin_changeform_product_prefill_on_save_addanother(self): self.client.login(username=self.user.username, password="secret") diff --git a/product_portfolio/tests/test_filters.py b/product_portfolio/tests/test_filters.py index 1dfa6173..f5f0c070 100644 --- a/product_portfolio/tests/test_filters.py +++ b/product_portfolio/tests/test_filters.py @@ -26,6 +26,10 @@ from product_portfolio.tests import make_product_component from product_portfolio.tests import make_product_package from vulnerabilities.tests import make_vulnerability +from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.models import ProductTriageRuleset +from vulnerabilities.triage.models import TriageAction +from vulnerabilities.triage.models import TriageRuleset class ProductPackageFilterSetTestCase(TestCase): @@ -187,6 +191,47 @@ def test_filter_by_vulnerability_rule_returns_distinct_results(self): self.assertEqual(1, filterset.qs.count()) +class ProductPackageFilterByTriageActionTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.pp_with_recommendation = make_product_package( + self.product, make_package(self.dataspace) + ) + self.pp_without_recommendation = make_product_package( + self.product, make_package(self.dataspace) + ) + make_vulnerability( + self.dataspace, affecting=self.pp_with_recommendation.package, risk_score=9.0 + ) + ruleset = TriageRuleset.objects.create( + name="Upgrade Ruleset", + action=TriageAction.UPGRADE, + precedence=100, + dataspace=self.dataspace, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + ProductTriageRuleset.objects.create( + product=self.product, ruleset=ruleset, dataspace=self.dataspace + ) + evaluate_ruleset(ruleset, self.product) + + def test_filter_by_triage_action_filters_matching_packages(self): + filterset = ProductPackageFilterSet( + dataspace=self.dataspace, + data={"triage_action": "upgrade"}, + ) + self.assertIn(self.pp_with_recommendation, filterset.qs) + self.assertNotIn(self.pp_without_recommendation, filterset.qs) + + def test_filter_by_triage_action_excludes_non_matching_action(self): + filterset = ProductPackageFilterSet( + dataspace=self.dataspace, + data={"triage_action": "notify"}, + ) + self.assertNotIn(self.pp_with_recommendation, filterset.qs) + + class ProductComponentFilterByRuleTestCase(TestCase): def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index ba7d6732..83673af5 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -64,6 +64,7 @@ from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.tests import make_vulnerability from vulnerabilities.tests import make_vulnerability_analysis +from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import ProductTriageRuleset from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRuleset @@ -1994,6 +1995,12 @@ def test_product_portfolio_product_update_save_as_new(self): CodebaseResource.objects.create( path="/path1/", product=self.product1, dataspace=self.dataspace ) + ruleset = TriageRuleset.objects.create( + name="Upgrade Ruleset", precedence=100, dataspace=self.dataspace + ) + ProductTriageRuleset.objects.create( + product=self.product1, ruleset=ruleset, dataspace=self.dataspace + ) initial_product_count = Product.objects.get_queryset(self.super_user).count() data = { @@ -2015,6 +2022,7 @@ def test_product_portfolio_product_update_save_as_new(self): self.assertEqual(1, cloned_product.productcomponents.count()) self.assertEqual(1, cloned_product.productpackages.count()) self.assertEqual(1, cloned_product.codebaseresources.count()) + self.assertEqual(1, cloned_product.product_triage_rulesets.count()) def test_product_portfolio_product_delete_view(self): delete_url = self.product1.get_delete_url() @@ -3423,6 +3431,54 @@ def test_product_portfolio_vulnerability_analysis_form_view(self): self.assertEqual(vulnerability1, analysis.vulnerability) self.assertEqual("resolved", analysis.state) + def test_product_portfolio_apply_analysis_preset_view(self): + self.client.login(username=self.super_user.username, password="secret") + + package1 = make_package(self.dataspace) + vulnerability1 = make_vulnerability(self.dataspace, affecting=[package1]) + product1 = make_product(self.dataspace, inventory=[package1]) + product_package = ProductPackage.objects.get(product=product1, package=package1) + preset = AnalysisPreset.objects.create( + name="Preset1", + state="not_affected", + detail="Not deployed", + dataspace=self.dataspace, + ) + + url = reverse( + "product_portfolio:apply_analysis_preset", + args=[product_package.uuid, preset.pk, vulnerability1.advisory_uid], + ) + response = self.client.post(url) + self.assertEqual(200, response.status_code) + self.assertEqual(b'{"success": "applied"}', response.content) + analysis = VulnerabilityAnalysis.objects.get() + self.assertEqual(product_package, analysis.product_package) + self.assertEqual(vulnerability1, analysis.vulnerability) + self.assertEqual("not_affected", analysis.state) + self.assertEqual(preset, analysis.applied_by_preset) + + def test_product_portfolio_apply_analysis_preset_view_existing_analysis(self): + self.client.login(username=self.super_user.username, password="secret") + + package1 = make_package(self.dataspace) + vulnerability1 = make_vulnerability(self.dataspace, affecting=[package1]) + product1 = make_product(self.dataspace, inventory=[package1]) + product_package = ProductPackage.objects.get(product=product1, package=package1) + make_vulnerability_analysis(product_package, vulnerability1, state="exploitable") + preset = AnalysisPreset.objects.create( + name="Preset1", state="not_affected", dataspace=self.dataspace + ) + + url = reverse( + "product_portfolio:apply_analysis_preset", + args=[product_package.uuid, preset.pk, vulnerability1.advisory_uid], + ) + response = self.client.post(url) + self.assertEqual(400, response.status_code) + self.assertEqual(1, VulnerabilityAnalysis.objects.count()) + self.assertEqual("exploitable", VulnerabilityAnalysis.objects.get().state) + def test_product_portfolio_tab_compliance_view_empty(self): self.client.login(username="nexb_user", password="secret") url = self.product1.get_url("tab_compliance") @@ -4295,6 +4351,85 @@ def test_post_with_change_perm_evaluates_and_returns_hx_refresh(self, mock_evalu mock_evaluate.assert_called_once_with(self.product1) +class ManageTriageRulesetsViewTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.super_user = create_superuser("nexb_user", self.dataspace) + self.basic_user = create_user("basic_user", self.dataspace) + self.product1 = Product.objects.create( + name="Product1", version="1.0", dataspace=self.dataspace + ) + self.ruleset = TriageRuleset.objects.create( + name="Upgrade Ruleset", + action=TriageAction.UPGRADE, + precedence=100, + dataspace=self.dataspace, + ) + + def test_get_without_login_redirects(self): + url = self.product1.get_manage_triage_rulesets_url() + response = self.client.get(url) + self.assertEqual(302, response.status_code) + + def test_get_without_change_perm_returns_404(self): + self.client.login(username="basic_user", password="secret") + url = self.product1.get_manage_triage_rulesets_url() + response = self.client.get(url) + self.assertEqual(404, response.status_code) + + def test_get_with_change_perm_renders_available_rulesets(self): + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_manage_triage_rulesets_url() + response = self.client.get(url) + self.assertEqual(200, response.status_code) + self.assertContains(response, self.ruleset.name) + self.assertNotContains(response, "checked") + + def test_get_marks_the_assigned_rulesets_as_checked(self): + ProductTriageRuleset.objects.create( + product=self.product1, ruleset=self.ruleset, dataspace=self.dataspace + ) + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_manage_triage_rulesets_url() + response = self.client.get(url) + self.assertContains(response, "checked") + + def test_post_without_change_perm_returns_404(self): + self.client.login(username="basic_user", password="secret") + url = self.product1.get_manage_triage_rulesets_url() + response = self.client.post(url) + self.assertEqual(404, response.status_code) + + @patch("product_portfolio.views.evaluate_ruleset") + def test_post_assigns_the_submitted_rulesets(self, mock_evaluate): + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_manage_triage_rulesets_url() + response = self.client.post(url, {"ruleset_uuids": [str(self.ruleset.uuid)]}) + self.assertEqual(b'{"success": true}', response.content) + self.assertTrue( + ProductTriageRuleset.objects.filter( + product=self.product1, ruleset=self.ruleset + ).exists() + ) + mock_evaluate.assert_called_once_with(ruleset=self.ruleset, product=self.product1) + + @patch("product_portfolio.views.evaluate_ruleset") + def test_post_unassigns_the_deselected_rulesets(self, mock_evaluate): + ProductTriageRuleset.objects.create( + product=self.product1, ruleset=self.ruleset, dataspace=self.dataspace + ) + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_manage_triage_rulesets_url() + response = self.client.post(url, {"ruleset_uuids": []}) + self.assertEqual(b'{"success": true}', response.content) + self.assertFalse( + ProductTriageRuleset.objects.filter( + product=self.product1, ruleset=self.ruleset + ).exists() + ) + mock_evaluate.assert_not_called() + + class TabCompliancePolicyContextTestCase(TestCase): def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") diff --git a/vulnerabilities/tests/test_forms.py b/vulnerabilities/tests/test_forms.py index 31bd71d6..e964a4e3 100644 --- a/vulnerabilities/tests/test_forms.py +++ b/vulnerabilities/tests/test_forms.py @@ -16,6 +16,7 @@ from product_portfolio.tests import make_product_package from vulnerabilities.forms import VulnerabilityAnalysisForm from vulnerabilities.tests import make_vulnerability +from vulnerabilities.triage.models import AnalysisPreset class VulnerabilitiesFormsTestCase(TestCase): @@ -49,6 +50,31 @@ def test_vulnerability_forms_vulnerability_analysis_save(self): self.assertEqual(product_package1.package, analysis.package) self.assertEqual(data["detail"], analysis.detail) + def test_vulnerability_forms_vulnerability_analysis_save_clears_applied_by_preset(self): + product_package1 = make_product_package(make_product(self.dataspace)) + vulnerability1 = make_vulnerability( + dataspace=self.dataspace, affecting=[product_package1.package] + ) + preset = AnalysisPreset.objects.create( + name="Preset1", state="not_affected", dataspace=self.dataspace + ) + analysis = product_package1.vulnerability_analyses.create( + vulnerability=vulnerability1, + dataspace=self.dataspace, + state="not_affected", + applied_by_preset=preset, + ) + + data = { + "product_package": product_package1, + "vulnerability": vulnerability1, + "detail": "Human edit", + } + form = VulnerabilityAnalysisForm(user=self.super_user, data=data, instance=analysis) + self.assertTrue(form.is_valid()) + saved_analysis = form.save() + self.assertIsNone(saved_analysis.applied_by_preset) + def test_vulnerability_forms_vulnerability_analysis_propagate_to_products(self): product_package1 = make_product_package(make_product(self.dataspace)) vulnerability1 = make_vulnerability( diff --git a/vulnerabilities/triage/tests/test_forms.py b/vulnerabilities/triage/tests/test_forms.py new file mode 100644 index 00000000..c8b7e032 --- /dev/null +++ b/vulnerabilities/triage/tests/test_forms.py @@ -0,0 +1,63 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from django.test import TestCase + +from dje.models import Dataspace +from vulnerabilities.triage.forms import TriageRulesetForm +from vulnerabilities.triage.models import TriageRuleset + + +class TriageRulesetFormTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + + def test_adds_a_dynamic_field_pair_for_every_registered_rule(self): + form = TriageRulesetForm(instance=TriageRuleset(dataspace=self.dataspace)) + self.assertIn("rule_risk_score_enabled", form.fields) + self.assertIn("rule_risk_score_min_risk_score", form.fields) + self.assertIn("rule_exploited_vulnerability_enabled", form.fields) + self.assertNotIn("rule_exploited_vulnerability_min_risk_score", form.fields) + + def test_save_builds_rules_config_from_the_submitted_rule_fields(self): + data = { + "name": "My Ruleset", + "precedence": 100, + "rule_risk_score_enabled": "on", + "rule_risk_score_min_risk_score": "7.5", + "rule_exploited_vulnerability_enabled": "on", + } + form = TriageRulesetForm(data=data, instance=TriageRuleset(dataspace=self.dataspace)) + self.assertTrue(form.is_valid(), form.errors) + + ruleset = form.save() + + self.assertEqual( + {"is_active": True, "min_risk_score": 7.5}, ruleset.rules_config["risk_score"] + ) + self.assertEqual({"is_active": True}, ruleset.rules_config["exploited_vulnerability"]) + + def test_save_drops_an_inactive_rule_that_has_no_parameters(self): + data = {"name": "My Ruleset", "precedence": 100} + form = TriageRulesetForm(data=data, instance=TriageRuleset(dataspace=self.dataspace)) + self.assertTrue(form.is_valid(), form.errors) + + ruleset = form.save() + + self.assertNotIn("exploited_vulnerability", ruleset.rules_config) + + def test_save_keeps_an_inactive_rule_that_has_parameters_with_their_defaults(self): + data = {"name": "My Ruleset", "precedence": 100} + form = TriageRulesetForm(data=data, instance=TriageRuleset(dataspace=self.dataspace)) + self.assertTrue(form.is_valid(), form.errors) + + ruleset = form.save() + + self.assertEqual( + {"is_active": False, "min_risk_score": 8.0}, ruleset.rules_config["risk_score"] + ) From f3a5e2f3a4f7794ca501fe4951eac147c5f024ce Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 14 Aug 2026 16:40:20 +0200 Subject: [PATCH 63/90] do not delete the link to requests Signed-off-by: tdruez --- vulnerabilities/triage/engine.py | 4 +- vulnerabilities/triage/signals.py | 18 +-- vulnerabilities/triage/tests/test_engine.py | 31 +++++ vulnerabilities/triage/tests/test_signals.py | 122 +++++++++++++++++++ 4 files changed, 165 insertions(+), 10 deletions(-) diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index c8947967..d223b269 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -176,7 +176,9 @@ def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id, ap vulnerability_ids=stale_vulnerability_ids, ) - stale_records_qs.delete() + # Records with an open Request are kept so a later rematch reconnects to it (via the + # update_conflicts upsert above) instead of opening a duplicate Request. + stale_records_qs.filter(request__isnull=True).delete() if apply_preset and ruleset.analysis_preset_id and matched_rules_per_vulnerability_id: apply_preset_for_vulnerabilities( diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index b1f9170a..7db616ab 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -30,7 +30,9 @@ def reevaluate_product_rulesets(product, apply_preset=True): def reevaluate_or_delete_on_ruleset_save(sender, instance, created, **kwargs): """Re-evaluate assigned products on config change; delete records when disabled.""" if not instance.enabled: - instance.triage_records.all().delete() + # Records with an open Request are kept so re-enabling the ruleset reconnects to it + # instead of opening a duplicate Request. + instance.triage_records.filter(request__isnull=True).delete() return if created: @@ -43,16 +45,14 @@ def reevaluate_or_delete_on_ruleset_save(sender, instance, created, **kwargs): @receiver(post_delete, sender="vulnerabilities_triage.ProductTriageRuleset") def delete_triage_records_on_unassign(sender, instance, **kwargs): """Delete triage records and associated preset analyses when a ruleset is de-assigned.""" - stale_vulnerability_ids = list( - TriageRecord.objects.filter( - ruleset=instance.ruleset, - product=instance.product, - ).values_list("vulnerability_id", flat=True) - ) - TriageRecord.objects.filter( + matching_records = TriageRecord.objects.filter( ruleset=instance.ruleset, product=instance.product, - ).delete() + ) + stale_vulnerability_ids = list(matching_records.values_list("vulnerability_id", flat=True)) + # Records with an open Request are kept so reassigning the ruleset reconnects to it + # instead of opening a duplicate Request. + matching_records.filter(request__isnull=True).delete() if instance.ruleset.analysis_preset_id and stale_vulnerability_ids: delete_preset_analyses_for_product( preset_id=instance.ruleset.analysis_preset_id, diff --git a/vulnerabilities/triage/tests/test_engine.py b/vulnerabilities/triage/tests/test_engine.py index 552c170f..3e6e7d51 100644 --- a/vulnerabilities/triage/tests/test_engine.py +++ b/vulnerabilities/triage/tests/test_engine.py @@ -225,6 +225,37 @@ def test_deletes_records_for_vulnerabilities_that_no_longer_match(self): sync_triage_records(self.ruleset, self.product, {}) self.assertFalse(TriageRecord.objects.exists()) + def test_keeps_a_stale_record_that_has_an_open_request(self): + # A record with a Request already attached must survive going stale, so a later + # rematch reconnects to the same Request instead of opening a duplicate. + sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) + record = TriageRecord.objects.get() + request = Request.objects.create( + request_template=RequestTemplate.objects.create( + name="Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=create_user("requester", self.dataspace), + ), + dataspace=self.dataspace, + requester=create_user("requester2", self.dataspace), + title="Vulnerability request", + product_context=self.product, + ) + record.request = request + record.save() + + sync_triage_records(self.ruleset, self.product, {}) + self.assertEqual(1, TriageRecord.objects.count()) + record.refresh_from_db() + self.assertEqual(request, record.request) + + sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) + self.assertEqual(1, TriageRecord.objects.count()) + record.refresh_from_db() + self.assertEqual(request, record.request) + def test_does_not_touch_records_from_another_ruleset(self): other_ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.NOTIFY) sync_triage_records(other_ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) diff --git a/vulnerabilities/triage/tests/test_signals.py b/vulnerabilities/triage/tests/test_signals.py index 6a204c5f..104ea516 100644 --- a/vulnerabilities/triage/tests/test_signals.py +++ b/vulnerabilities/triage/tests/test_signals.py @@ -8,10 +8,13 @@ from unittest.mock import patch +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from component_catalog.tests import make_package from dje.models import Dataspace +from dje.tests import create_user +from product_portfolio.models import Product from product_portfolio.tests import make_product from product_portfolio.tests import make_product_package from vulnerabilities.models import VulnerabilityAnalysis @@ -26,6 +29,8 @@ from vulnerabilities.triage.tests import make_analysis_preset from vulnerabilities.triage.tests import make_product_triage_ruleset from vulnerabilities.triage.tests import make_triage_ruleset +from workflow.models import Request +from workflow.models import RequestTemplate class ReevaluateProductRulesetsTestCase(TestCase): @@ -120,6 +125,76 @@ def test_disabling_a_ruleset_deletes_its_triage_records_instead_of_evaluating( self.assertFalse(TriageRecord.objects.exists()) mock_evaluate.assert_not_called() + @patch("vulnerabilities.triage.signals.evaluate_ruleset") + def test_disabling_a_ruleset_keeps_records_that_have_an_open_request(self, mock_evaluate): + package = make_package(self.dataspace) + make_product_package(self.product, package=package) + vulnerability = make_vulnerability(self.dataspace, affecting=package) + ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.NOTIFY) + make_product_triage_ruleset(self.product, ruleset=ruleset) + requester = create_user("requester", self.dataspace) + request_template = RequestTemplate.objects.create( + name="Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=requester, + ) + request = request_template.create_request( + requester=requester, + title="Vulnerability request", + product_context=self.product, + object_id=self.product.pk, + ) + record = TriageRecord.objects.create( + vulnerability=vulnerability, + product=self.product, + ruleset=ruleset, + action=ruleset.action, + request=request, + dataspace=self.dataspace, + ) + mock_evaluate.reset_mock() + + ruleset.enabled = False + ruleset.save() + + record.refresh_from_db() + self.assertEqual(request, record.request) + + def test_disabling_then_reenabling_a_ruleset_reuses_the_existing_request(self): + # Regression: disabling then re-enabling a ruleset used to reopen a new Request + # instead of reconnecting to the one already tracking this vulnerability. + package = make_package(self.dataspace) + make_product_package(self.product, package=package) + make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + requester = create_user("requester", self.dataspace) + request_template = RequestTemplate.objects.create( + name="Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=requester, + ) + ruleset = make_triage_ruleset( + self.dataspace, + action=TriageAction.NOTIFY, + request_template=request_template, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + make_product_triage_ruleset(self.product, ruleset=ruleset) + evaluate_ruleset(ruleset, self.product) + original_request = TriageRecord.objects.get().request + self.assertIsNotNone(original_request) + + ruleset.enabled = False + ruleset.save() + ruleset.enabled = True + ruleset.save() + + self.assertEqual(1, Request.objects.count()) + self.assertEqual(original_request, TriageRecord.objects.get().request) + class DeleteTriageRecordsOnUnassignSignalTestCase(TestCase): def setUp(self): @@ -143,6 +218,53 @@ def test_unassigning_the_ruleset_deletes_its_triage_records_for_the_product(self self.assignment.delete() self.assertFalse(TriageRecord.objects.exists()) + def test_unassigning_keeps_a_record_that_has_an_open_request(self): + requester = create_user("requester", self.dataspace) + request_template = RequestTemplate.objects.create( + name="Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=requester, + ) + request = request_template.create_request( + requester=requester, + title="Vulnerability request", + product_context=self.product, + object_id=self.product.pk, + ) + record = TriageRecord.objects.get() + record.request = request + record.save() + + self.assignment.delete() + + record.refresh_from_db() + self.assertEqual(request, record.request) + + def test_reassigning_the_ruleset_reuses_the_existing_request(self): + # Regression: unassigning then reassigning a ruleset used to reopen a new Request + # instead of reconnecting to the one already tracking this vulnerability. + requester = create_user("requester", self.dataspace) + request_template = RequestTemplate.objects.create( + name="Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=requester, + ) + self.ruleset.request_template = request_template + self.ruleset.save() + original_request = TriageRecord.objects.get().request + self.assertIsNotNone(original_request) + + self.assignment.delete() + make_product_triage_ruleset(self.product, ruleset=self.ruleset) + evaluate_ruleset(self.ruleset, self.product) + + self.assertEqual(1, Request.objects.count()) + self.assertEqual(original_request, TriageRecord.objects.get().request) + def test_does_not_delete_records_belonging_to_another_product(self): other_product = make_product(self.dataspace) make_product_package(other_product, package=self.package) From a9d0bb7875031f4cf6c95f298394f88a3fe8483c Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 14 Aug 2026 17:18:29 +0200 Subject: [PATCH 64/90] rename action as recommended_action Signed-off-by: tdruez --- product_portfolio/filters.py | 2 +- .../modals/manage_triage_rulesets_form.html | 2 +- .../tabs/tab_packages_vulnerabilities.html | 2 +- product_portfolio/tests/test_filters.py | 2 +- product_portfolio/tests/test_views.py | 4 +- product_portfolio/views.py | 14 ++++-- ...vulnerabilityanalysis_applied_by_preset.py | 4 +- vulnerabilities/triage/admin.py | 6 +-- vulnerabilities/triage/engine.py | 4 +- vulnerabilities/triage/forms.py | 4 +- .../commands/create_triage_rulesets.py | 16 +++---- .../triage/migrations/0001_initial.py | 33 ++++++++++++-- ...sispreset_triageruleset_analysis_preset.py | 43 ------------------- ..._request_triageruleset_request_template.py | 25 ----------- vulnerabilities/triage/models.py | 8 ++-- vulnerabilities/triage/tests/test_engine.py | 16 +++---- vulnerabilities/triage/tests/test_models.py | 17 +++++--- vulnerabilities/triage/tests/test_signals.py | 12 +++--- 18 files changed, 90 insertions(+), 124 deletions(-) delete mode 100644 vulnerabilities/triage/migrations/0002_analysispreset_triageruleset_analysis_preset.py delete mode 100644 vulnerabilities/triage/migrations/0003_triagerecord_request_triageruleset_request_template.py diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index ba9f2a91..b863712a 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -467,7 +467,7 @@ def filter_triage_action(queryset, name, value): vulnerability__affected_packages__productpackages=OuterRef("pk"), ruleset__enabled=True, ruleset_id=Subquery(winning_ruleset_id), - action=value, + recommended_action=value, ) return queryset.filter(Exists(primary_triage)).distinct() diff --git a/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html b/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html index 5c7f5d6f..fec0b8ac 100644 --- a/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html +++ b/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html @@ -33,7 +33,7 @@ {% endfor %} {% endif %} - {% if ruleset.action %} + {% if ruleset.recommended_action %} {% trans "Recommended action:" %} diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index a0f75266..d7332d23 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -78,7 +78,7 @@ {% if vulnerability.triage_record %} {% with record=vulnerability.triage_record %} - {% if record.action %} + {% if record.recommended_action %} {{ record.action_label }} diff --git a/product_portfolio/tests/test_filters.py b/product_portfolio/tests/test_filters.py index f5f0c070..7d5496b2 100644 --- a/product_portfolio/tests/test_filters.py +++ b/product_portfolio/tests/test_filters.py @@ -206,7 +206,7 @@ def setUp(self): ) ruleset = TriageRuleset.objects.create( name="Upgrade Ruleset", - action=TriageAction.UPGRADE, + recommended_action=TriageAction.UPGRADE, precedence=100, dataspace=self.dataspace, rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 83673af5..c07c6a7e 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -306,7 +306,7 @@ def test_product_portfolio_tab_vulnerability_view_filters(self): # product has at least one enabled TriageRuleset assigned to it. ruleset = TriageRuleset.objects.create( name="Upgrade Ruleset", - action=TriageAction.UPGRADE, + recommended_action=TriageAction.UPGRADE, precedence=100, dataspace=self.dataspace, ) @@ -4361,7 +4361,7 @@ def setUp(self): ) self.ruleset = TriageRuleset.objects.create( name="Upgrade Ruleset", - action=TriageAction.UPGRADE, + recommended_action=TriageAction.UPGRADE, precedence=100, dataspace=self.dataspace, ) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index f77d4be6..83af376b 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -1349,8 +1349,12 @@ def attach_triage_data(self, product, page_obj): .select_related("ruleset", "request") ) for record in triage_records: - record.action_label = action_labels.get(record.action, record.action) - badge_class, icon = TRIAGE_ACTION_STYLES.get(record.action, TRIAGE_ACTION_DEFAULT_STYLE) + record.action_label = action_labels.get( + record.recommended_action, record.recommended_action + ) + badge_class, icon = TRIAGE_ACTION_STYLES.get( + record.recommended_action, TRIAGE_ACTION_DEFAULT_STYLE + ) record.action_badge_class = badge_class record.action_icon = icon @@ -2251,9 +2255,11 @@ def manage_triage_rulesets_view(request, dataspace, name, version=""): action_labels = dict(TriageAction.choices) for ruleset in available_rulesets: - ruleset.action_label = action_labels.get(ruleset.action, ruleset.action) + ruleset.action_label = action_labels.get( + ruleset.recommended_action, ruleset.recommended_action + ) action_badge_class, action_icon = TRIAGE_ACTION_STYLES.get( - ruleset.action, TRIAGE_ACTION_DEFAULT_STYLE + ruleset.recommended_action, TRIAGE_ACTION_DEFAULT_STYLE ) ruleset.action_badge_class = action_badge_class ruleset.action_icon = action_icon diff --git a/vulnerabilities/migrations/0011_vulnerabilityanalysis_applied_by_preset.py b/vulnerabilities/migrations/0011_vulnerabilityanalysis_applied_by_preset.py index c10caf04..8d4c3323 100644 --- a/vulnerabilities/migrations/0011_vulnerabilityanalysis_applied_by_preset.py +++ b/vulnerabilities/migrations/0011_vulnerabilityanalysis_applied_by_preset.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.6 on 2026-08-12 06:37 +# Generated by Django 6.0.6 on 2026-08-14 15:02 import django.db.models.deletion from django.db import migrations, models @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('vulnerabilities', '0010_api_v3_remove_old_fields'), - ('vulnerabilities_triage', '0002_analysispreset_triageruleset_analysis_preset'), + ('vulnerabilities_triage', '0001_initial'), ] operations = [ diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 8f49b896..57e1a714 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -101,7 +101,7 @@ class TriageRulesetAdmin(DataspacedAdmin): @admin.display(description="Action") def get_action_label(self, obj): - return dict(TriageAction.choices).get(obj.action, obj.action) + return dict(TriageAction.choices).get(obj.recommended_action, obj.recommended_action) @admin.display(description="Enabled rules") def get_enabled_rules(self, obj): @@ -134,7 +134,7 @@ def get_form(self, request, obj=None, change=False, **kwargs): kwargs["fields"] = [ "name", "description", - "action", + "recommended_action", "precedence", "enabled", "analysis_preset", @@ -150,7 +150,7 @@ def get_fieldsets(self, request, obj=None): "fields": [ "name", "description", - "action", + "recommended_action", "precedence", "enabled", "analysis_preset", diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index d223b269..801bf20a 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -147,7 +147,7 @@ def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id, ap vulnerability_id=vulnerability_id, product=product, ruleset=ruleset, - action=ruleset.action, + recommended_action=ruleset.recommended_action, matched_rules=matched_rules, dataspace=ruleset.dataspace, detected_date=now, @@ -159,7 +159,7 @@ def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id, ap records, update_conflicts=True, unique_fields=["vulnerability", "product", "ruleset"], - update_fields=["action", "matched_rules", "last_checked", "dataspace"], + update_fields=["recommended_action", "matched_rules", "last_checked", "dataspace"], ) stale_records_qs = TriageRecord.objects.filter( diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py index 6de9ee75..7fcbf660 100644 --- a/vulnerabilities/triage/forms.py +++ b/vulnerabilities/triage/forms.py @@ -37,7 +37,7 @@ class Meta: class TriageRulesetForm(DataspacedAdminForm): - action = forms.ChoiceField( + recommended_action = forms.ChoiceField( choices=[("", "---------")] + list(TriageAction.choices), required=False, ) @@ -47,7 +47,7 @@ class Meta: fields = [ "name", "description", - "action", + "recommended_action", "precedence", "enabled", "analysis_preset", diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index 73b5b0d4..df340e81 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -57,7 +57,7 @@ "Vulnerabilities with a critical risk score and a known active exploit" " affecting the product." ), - "action": TriageAction.UPGRADE, + "recommended_action": TriageAction.UPGRADE, "precedence": 700, "rules_config": { "risk_score": {"is_active": True, "min_risk_score": 8.0}, @@ -70,7 +70,7 @@ "Vulnerabilities with a known active exploit affecting the product," " regardless of severity." ), - "action": TriageAction.UPGRADE, + "recommended_action": TriageAction.UPGRADE, "precedence": 600, "rules_config": { "exploited_vulnerability": {"is_active": True}, @@ -79,7 +79,7 @@ { "name": "Reachable Vulnerability", "description": "Vulnerabilities confirmed as reachable within the product context.", - "action": TriageAction.APPLY_PATCH, + "recommended_action": TriageAction.APPLY_PATCH, "precedence": 500, "rules_config": { "reachable_vulnerability": {"is_active": True}, @@ -91,7 +91,7 @@ "Vulnerabilities with a critical risk score and no known active exploit" " affecting the product." ), - "action": TriageAction.APPLY_PATCH, + "recommended_action": TriageAction.APPLY_PATCH, "precedence": 400, "rules_config": { "risk_score": {"is_active": True, "min_risk_score": 8.0}, @@ -103,7 +103,7 @@ "Vulnerabilities with a critical risk score left unaddressed" " for more than 30 days in the product." ), - "action": TriageAction.APPLY_PATCH, + "recommended_action": TriageAction.APPLY_PATCH, "precedence": 300, "rules_config": { "stale_vulnerability": {"is_active": True, "min_risk_score": 8.0, "max_days": 30}, @@ -112,7 +112,7 @@ { "name": "Dev-Only Vulnerable Package", "description": "Vulnerabilities affecting only non-deployed packages in the product.", - "action": TriageAction.NOTIFY, + "recommended_action": TriageAction.NOTIFY, "precedence": 200, "rules_config": { "dev_only_vulnerable_package": {"is_active": True}, @@ -124,7 +124,7 @@ "Vulnerabilities affecting the product where at least one package" " has no completed triage analysis." ), - "action": TriageAction.FORENSIC_ANALYSIS, + "recommended_action": TriageAction.FORENSIC_ANALYSIS, "precedence": 100, "rules_config": { "unresolved_vulnerability": {"is_active": True}, @@ -169,7 +169,7 @@ def handle(self, *args, **options): name=ruleset_data["name"], defaults={ "description": ruleset_data["description"], - "action": ruleset_data["action"], + "recommended_action": ruleset_data["recommended_action"], "precedence": ruleset_data["precedence"], "rules_config": ruleset_data["rules_config"], "enabled": True, diff --git a/vulnerabilities/triage/migrations/0001_initial.py b/vulnerabilities/triage/migrations/0001_initial.py index 8e52ac5e..02a1bdbd 100644 --- a/vulnerabilities/triage/migrations/0001_initial.py +++ b/vulnerabilities/triage/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 6.0.6 on 2026-08-11 13:44 +# Generated by Django 6.0.6 on 2026-08-14 15:02 +import django.contrib.postgres.fields import django.db.models.deletion import dje.models import uuid @@ -14,9 +15,30 @@ class Migration(migrations.Migration): ('dje', '0016_dataspaceconfiguration_policy_rules_config'), ('product_portfolio', '0019_productaffectedbyvulnerability_detected_date'), ('vulnerabilities', '0010_api_v3_remove_old_fields'), + ('workflow', '0003_externalissuelink_base_url'), ] operations = [ + migrations.CreateModel( + name='AnalysisPreset', + 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')), + ('state', models.CharField(blank=True, choices=[('resolved', 'Resolved'), ('resolved_with_pedigree', 'Resolved With Pedigree'), ('exploitable', 'Exploitable'), ('in_triage', 'In Triage'), ('false_positive', 'False Positive'), ('not_affected', 'Not Affected')], help_text='Declares the current state of an occurrence of a vulnerability, after automated or manual analysis.', max_length=25)), + ('justification', models.CharField(blank=True, choices=[('code_not_present', 'Code Not Present'), ('code_not_reachable', 'Code Not Reachable'), ('protected_at_perimeter', 'Protected At Perimeter'), ('protected_at_runtime', 'Protected At Runtime'), ('protected_by_compiler', 'Protected By Compiler'), ('protected_by_mitigating_control', 'Protected By Mitigating Control'), ('requires_configuration', 'Requires Configuration'), ('requires_dependency', 'Requires Dependency'), ('requires_environment', 'Requires Environment')], help_text='The rationale of why the impact analysis state was asserted.', max_length=35)), + ('responses', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('can_not_fix', 'Can Not Fix'), ('rollback', 'Rollback'), ('update', 'Update'), ('will_not_fix', 'Will Not Fix'), ('workaround_available', 'Workaround Available')], max_length=20), blank=True, help_text='A response to the vulnerability by the manufacturer, supplier, or project responsible for the affected component or service. More than one response is allowed. Responses are strongly encouraged for vulnerabilities where the analysis state is exploitable.', null=True)), + ('detail', models.TextField(blank=True, help_text='Detailed description of the impact including methods used during assessment. If a vulnerability is not exploitable, this field should include specific details on why the component or service is not impacted by this vulnerability.')), + ('name', models.CharField(help_text='Short name identifying this analysis preset.', max_length=100)), + ('description', models.TextField(blank=True, help_text='Optional description of when and why this preset is applied.')), + ('is_reachable', models.BooleanField(blank=True, help_text='Reachability value to set on the analysis. Leave blank to leave the field unchanged.', null=True)), + ('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')), + ], + options={ + 'ordering': ('name',), + 'unique_together': {('dataspace', 'name'), ('dataspace', 'uuid')}, + }, + bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), + ), migrations.CreateModel( name='TriageRuleset', fields=[ @@ -24,11 +46,13 @@ class Migration(migrations.Migration): ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), ('name', models.CharField(help_text='Short name identifying this triage ruleset.', max_length=100)), ('description', models.TextField(blank=True, help_text='Optional description of the purpose or scope of this ruleset.')), - ('action', models.CharField(blank=True, help_text="Action recommended when this ruleset's conditions are met.", max_length=50)), - ('precedence', models.PositiveIntegerField(default=100, help_text='When multiple rulesets are assigned to a product and produce different actions, the one with the highest precedence takes effect.')), + ('recommended_action', models.CharField(blank=True, help_text="Remediation action recommended when this ruleset's conditions are met.", max_length=50)), + ('precedence', models.PositiveIntegerField(help_text='When multiple rulesets are assigned to a product and produce different actions, the one with the highest precedence takes effect.')), ('enabled', models.BooleanField(default=True, help_text='Activate or deactivate this ruleset without deleting it.')), ('rules_config', models.JSONField(blank=True, default=dict, help_text='Active rules for this ruleset, keyed by rule type.')), + ('analysis_preset', models.ForeignKey(blank=True, help_text='Optional preset automatically applied to matching vulnerability analyses. Only applied when no human-owned analysis exists.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_rulesets', to='vulnerabilities_triage.analysispreset')), ('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')), + ('request_template', models.ForeignKey(blank=True, help_text='Optional product-type request template. When set, the triage engine automatically opens a request for each newly detected vulnerability match.', limit_choices_to={'content_type__app_label': 'product_portfolio', 'content_type__model': 'product'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_rulesets', to='workflow.requesttemplate')), ], options={ 'ordering': ('-precedence', 'name'), @@ -41,12 +65,13 @@ class Migration(migrations.Migration): 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')), - ('action', models.CharField(blank=True, help_text='Recommended action captured at the time of evaluation.', max_length=50)), + ('recommended_action', models.CharField(blank=True, help_text='Recommended action captured at the time of evaluation.', max_length=50)), ('matched_rules', models.JSONField(default=list, help_text='Rules that fired for this vulnerability during evaluation.')), ('detected_date', models.DateTimeField(auto_now_add=True, help_text='Date and time when this recommendation was first generated.')), ('last_checked', models.DateTimeField(auto_now=True, help_text='Date and time of the last evaluation.')), ('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 this recommendation applies to.', on_delete=django.db.models.deletion.CASCADE, related_name='triage_records', to='product_portfolio.product')), + ('request', models.ForeignKey(blank=True, help_text='Request automatically opened by the triage engine for this vulnerability match.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_records', to='workflow.request')), ('vulnerability', models.ForeignKey(help_text='The vulnerability that triggered this recommendation.', on_delete=django.db.models.deletion.CASCADE, related_name='triage_records', to='vulnerabilities.vulnerability')), ('ruleset', models.ForeignKey(help_text='The ruleset that produced this action.', on_delete=django.db.models.deletion.CASCADE, related_name='triage_records', to='vulnerabilities_triage.triageruleset')), ], diff --git a/vulnerabilities/triage/migrations/0002_analysispreset_triageruleset_analysis_preset.py b/vulnerabilities/triage/migrations/0002_analysispreset_triageruleset_analysis_preset.py deleted file mode 100644 index a9224652..00000000 --- a/vulnerabilities/triage/migrations/0002_analysispreset_triageruleset_analysis_preset.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 6.0.6 on 2026-08-12 06:37 - -import django.contrib.postgres.fields -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'), - ('vulnerabilities_triage', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='AnalysisPreset', - 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')), - ('state', models.CharField(blank=True, choices=[('resolved', 'Resolved'), ('resolved_with_pedigree', 'Resolved With Pedigree'), ('exploitable', 'Exploitable'), ('in_triage', 'In Triage'), ('false_positive', 'False Positive'), ('not_affected', 'Not Affected')], help_text='Declares the current state of an occurrence of a vulnerability, after automated or manual analysis.', max_length=25)), - ('justification', models.CharField(blank=True, choices=[('code_not_present', 'Code Not Present'), ('code_not_reachable', 'Code Not Reachable'), ('protected_at_perimeter', 'Protected At Perimeter'), ('protected_at_runtime', 'Protected At Runtime'), ('protected_by_compiler', 'Protected By Compiler'), ('protected_by_mitigating_control', 'Protected By Mitigating Control'), ('requires_configuration', 'Requires Configuration'), ('requires_dependency', 'Requires Dependency'), ('requires_environment', 'Requires Environment')], help_text='The rationale of why the impact analysis state was asserted.', max_length=35)), - ('responses', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('can_not_fix', 'Can Not Fix'), ('rollback', 'Rollback'), ('update', 'Update'), ('will_not_fix', 'Will Not Fix'), ('workaround_available', 'Workaround Available')], max_length=20), blank=True, help_text='A response to the vulnerability by the manufacturer, supplier, or project responsible for the affected component or service. More than one response is allowed. Responses are strongly encouraged for vulnerabilities where the analysis state is exploitable.', null=True)), - ('detail', models.TextField(blank=True, help_text='Detailed description of the impact including methods used during assessment. If a vulnerability is not exploitable, this field should include specific details on why the component or service is not impacted by this vulnerability.')), - ('name', models.CharField(help_text='Short name identifying this analysis preset.', max_length=100)), - ('description', models.TextField(blank=True, help_text='Optional description of when and why this preset is applied.')), - ('is_reachable', models.BooleanField(blank=True, help_text='Reachability value to set on the analysis. Leave blank to leave the field unchanged.', null=True)), - ('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')), - ], - options={ - 'ordering': ('name',), - 'unique_together': {('dataspace', 'name'), ('dataspace', 'uuid')}, - }, - bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), - ), - migrations.AddField( - model_name='triageruleset', - name='analysis_preset', - field=models.ForeignKey(blank=True, help_text='Optional preset automatically applied to matching vulnerability analyses. Only applied when no human-owned analysis exists.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_rulesets', to='vulnerabilities_triage.analysispreset'), - ), - ] diff --git a/vulnerabilities/triage/migrations/0003_triagerecord_request_triageruleset_request_template.py b/vulnerabilities/triage/migrations/0003_triagerecord_request_triageruleset_request_template.py deleted file mode 100644 index 472f0f2f..00000000 --- a/vulnerabilities/triage/migrations/0003_triagerecord_request_triageruleset_request_template.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 6.0.6 on 2026-08-13 08:01 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('vulnerabilities_triage', '0002_analysispreset_triageruleset_analysis_preset'), - ('workflow', '0003_externalissuelink_base_url'), - ] - - operations = [ - migrations.AddField( - model_name='triagerecord', - name='request', - field=models.ForeignKey(blank=True, help_text='Request automatically opened by the triage engine for this vulnerability match.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_records', to='workflow.request'), - ), - migrations.AddField( - model_name='triageruleset', - name='request_template', - field=models.ForeignKey(blank=True, help_text='Optional product-type request template. When set, the triage engine automatically opens a request for each newly detected vulnerability match.', limit_choices_to={'content_type__app_label': 'product_portfolio', 'content_type__model': 'product'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_rulesets', to='workflow.requesttemplate'), - ), - ] diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index 91a44831..b61974dd 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -72,10 +72,10 @@ class TriageRuleset(DataspacedModel): blank=True, help_text=_("Optional description of the purpose or scope of this ruleset."), ) - action = models.CharField( + recommended_action = models.CharField( max_length=50, blank=True, - help_text=_("Action recommended when this ruleset's conditions are met."), + help_text=_("Remediation action recommended when this ruleset's conditions are met."), ) precedence = models.PositiveIntegerField( help_text=_( @@ -201,7 +201,7 @@ class TriageRecord(DataspacedModel): related_name="triage_records", help_text=_("The ruleset that produced this action."), ) - action = models.CharField( + recommended_action = models.CharField( max_length=50, blank=True, help_text=_("Recommended action captured at the time of evaluation."), @@ -236,4 +236,4 @@ class Meta: ordering = ["-detected_date"] def __str__(self): - return f"{self.vulnerability} / {self.product} / {self.ruleset}: {self.action}" + return f"{self.vulnerability} / {self.product} / {self.ruleset}: {self.recommended_action}" diff --git a/vulnerabilities/triage/tests/test_engine.py b/vulnerabilities/triage/tests/test_engine.py index 3e6e7d51..8d59e672 100644 --- a/vulnerabilities/triage/tests/test_engine.py +++ b/vulnerabilities/triage/tests/test_engine.py @@ -194,7 +194,7 @@ def setUp(self): self.package = make_package(self.dataspace) self.product_package = make_product_package(self.product, package=self.package) self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package) - self.ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.UPGRADE) + self.ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.UPGRADE) def test_creates_one_record_per_matching_vulnerability(self): sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) @@ -202,7 +202,7 @@ def test_creates_one_record_per_matching_vulnerability(self): self.assertEqual(self.vulnerability, record.vulnerability) self.assertEqual(self.product, record.product) self.assertEqual(self.ruleset, record.ruleset) - self.assertEqual(TriageAction.UPGRADE, record.action) + self.assertEqual(TriageAction.UPGRADE, record.recommended_action) self.assertEqual(["risk_score"], record.matched_rules) def test_reevaluation_updates_matched_rules_without_duplicating_the_record(self): @@ -257,7 +257,7 @@ def test_keeps_a_stale_record_that_has_an_open_request(self): self.assertEqual(request, record.request) def test_does_not_touch_records_from_another_ruleset(self): - other_ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.NOTIFY) + other_ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.NOTIFY) sync_triage_records(other_ruleset, self.product, {self.vulnerability.pk: ["risk_score"]}) sync_triage_records(self.ruleset, self.product, {}) self.assertEqual(1, TriageRecord.objects.count()) @@ -337,7 +337,7 @@ def setUp(self): self.package = make_package(self.dataspace) make_product_package(self.product, package=self.package) self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package) - self.ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.UPGRADE) + self.ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.UPGRADE) self.requester = create_user("requester", self.dataspace) self.request_template = RequestTemplate.objects.create( name="Vulnerability Template", @@ -352,7 +352,7 @@ def test_opens_one_request_per_record_using_the_template_creator_as_requester(se vulnerability=self.vulnerability, product=self.product, ruleset=self.ruleset, - action=self.ruleset.action, + recommended_action=self.ruleset.recommended_action, dataspace=self.dataspace, ) create_triage_requests(self.request_template, self.product, [record]) @@ -367,7 +367,7 @@ def test_sets_the_request_on_the_triage_record(self): vulnerability=self.vulnerability, product=self.product, ruleset=self.ruleset, - action=self.ruleset.action, + recommended_action=self.ruleset.recommended_action, dataspace=self.dataspace, ) create_triage_requests(self.request_template, self.product, [record]) @@ -386,7 +386,7 @@ def test_creates_a_triage_record_end_to_end_for_a_matching_vulnerability(self): vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) ruleset = make_triage_ruleset( self.dataspace, - action=TriageAction.UPGRADE, + recommended_action=TriageAction.UPGRADE, rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, ) evaluate_ruleset(ruleset, self.product) @@ -398,7 +398,7 @@ def test_removes_the_record_once_the_vulnerability_no_longer_matches(self): vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) ruleset = make_triage_ruleset( self.dataspace, - action=TriageAction.UPGRADE, + recommended_action=TriageAction.UPGRADE, rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, ) evaluate_ruleset(ruleset, self.product) diff --git a/vulnerabilities/triage/tests/test_models.py b/vulnerabilities/triage/tests/test_models.py index 331af4cb..fafeb392 100644 --- a/vulnerabilities/triage/tests/test_models.py +++ b/vulnerabilities/triage/tests/test_models.py @@ -150,17 +150,20 @@ def setUp(self): self.package = make_package(self.dataspace) make_product_package(self.product, package=self.package) self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package) - self.ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.NOTIFY) + self.ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.NOTIFY) def test_str(self): record = TriageRecord.objects.create( vulnerability=self.vulnerability, product=self.product, ruleset=self.ruleset, - action=self.ruleset.action, + recommended_action=self.ruleset.recommended_action, dataspace=self.dataspace, ) - expected = f"{self.vulnerability} / {self.product} / {self.ruleset}: {self.ruleset.action}" + expected = ( + f"{self.vulnerability} / {self.product} /" + f" {self.ruleset}: {self.ruleset.recommended_action}" + ) self.assertEqual(expected, str(record)) @@ -177,16 +180,16 @@ def _make_record(self, ruleset): vulnerability=self.vulnerability, product=self.product, ruleset=ruleset, - action=ruleset.action, + recommended_action=ruleset.recommended_action, dataspace=self.dataspace, ) def test_returns_the_record_of_the_highest_precedence_assigned_ruleset(self): low_ruleset = make_triage_ruleset( - self.dataspace, precedence=100, action=TriageAction.NOTIFY + self.dataspace, precedence=100, recommended_action=TriageAction.NOTIFY ) high_ruleset = make_triage_ruleset( - self.dataspace, precedence=900, action=TriageAction.UPGRADE + self.dataspace, precedence=900, recommended_action=TriageAction.UPGRADE ) make_product_triage_ruleset(self.product, ruleset=low_ruleset) make_product_triage_ruleset(self.product, ruleset=high_ruleset) @@ -227,7 +230,7 @@ def test_evaluates_precedence_independently_per_product(self): vulnerability=self.vulnerability, product=other_product, ruleset=high_ruleset, - action=high_ruleset.action, + recommended_action=high_ruleset.recommended_action, dataspace=self.dataspace, ) diff --git a/vulnerabilities/triage/tests/test_signals.py b/vulnerabilities/triage/tests/test_signals.py index 104ea516..989f978b 100644 --- a/vulnerabilities/triage/tests/test_signals.py +++ b/vulnerabilities/triage/tests/test_signals.py @@ -108,13 +108,13 @@ def test_disabling_a_ruleset_deletes_its_triage_records_instead_of_evaluating( package = make_package(self.dataspace) make_product_package(self.product, package=package) vulnerability = make_vulnerability(self.dataspace, affecting=package) - ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.NOTIFY) + ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.NOTIFY) make_product_triage_ruleset(self.product, ruleset=ruleset) TriageRecord.objects.create( vulnerability=vulnerability, product=self.product, ruleset=ruleset, - action=ruleset.action, + recommended_action=ruleset.recommended_action, dataspace=self.dataspace, ) mock_evaluate.reset_mock() @@ -130,7 +130,7 @@ def test_disabling_a_ruleset_keeps_records_that_have_an_open_request(self, mock_ package = make_package(self.dataspace) make_product_package(self.product, package=package) vulnerability = make_vulnerability(self.dataspace, affecting=package) - ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.NOTIFY) + ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.NOTIFY) make_product_triage_ruleset(self.product, ruleset=ruleset) requester = create_user("requester", self.dataspace) request_template = RequestTemplate.objects.create( @@ -150,7 +150,7 @@ def test_disabling_a_ruleset_keeps_records_that_have_an_open_request(self, mock_ vulnerability=vulnerability, product=self.product, ruleset=ruleset, - action=ruleset.action, + recommended_action=ruleset.recommended_action, request=request, dataspace=self.dataspace, ) @@ -178,7 +178,7 @@ def test_disabling_then_reenabling_a_ruleset_reuses_the_existing_request(self): ) ruleset = make_triage_ruleset( self.dataspace, - action=TriageAction.NOTIFY, + recommended_action=TriageAction.NOTIFY, request_template=request_template, rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, ) @@ -207,7 +207,7 @@ def setUp(self): ) self.ruleset = make_triage_ruleset( self.dataspace, - action=TriageAction.NOTIFY, + recommended_action=TriageAction.NOTIFY, rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, ) self.assignment = make_product_triage_ruleset(self.product, ruleset=self.ruleset) From 373a1ab736134e684d78c087b924a274783852b7 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 14 Aug 2026 17:22:31 +0200 Subject: [PATCH 65/90] display analysis_preset in modal Signed-off-by: tdruez --- .../modals/manage_triage_rulesets_form.html | 8 ++++++++ product_portfolio/tests/test_views.py | 11 +++++++++++ product_portfolio/views.py | 6 +++--- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html b/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html index fec0b8ac..51a4aed6 100644 --- a/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html +++ b/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html @@ -41,6 +41,14 @@ {% endif %} + {% if ruleset.analysis_preset %} + + {% trans "Auto-applies preset:" %} + + {{ ruleset.analysis_preset.name }} + + + {% endif %} {% endfor %} diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index c07c6a7e..f5912a3b 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -4385,6 +4385,17 @@ def test_get_with_change_perm_renders_available_rulesets(self): self.assertContains(response, self.ruleset.name) self.assertNotContains(response, "checked") + def test_get_displays_the_ruleset_analysis_preset(self): + preset = AnalysisPreset.objects.create( + name="Auto-Close Preset", state="not_affected", dataspace=self.dataspace + ) + self.ruleset.analysis_preset = preset + self.ruleset.save() + self.client.login(username="nexb_user", password="secret") + url = self.product1.get_manage_triage_rulesets_url() + response = self.client.get(url) + self.assertContains(response, preset.name) + def test_get_marks_the_assigned_rulesets_as_checked(self): ProductTriageRuleset.objects.create( product=self.product1, ruleset=self.ruleset, dataspace=self.dataspace diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 83af376b..497c637b 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2223,9 +2223,9 @@ def manage_triage_rulesets_view(request, dataspace, name, version=""): dataspace__name=dataspace, ) available_rulesets = list( - TriageRuleset.objects.filter(dataspace=product.dataspace, enabled=True).order_by( - "-precedence", "name" - ) + TriageRuleset.objects.filter(dataspace=product.dataspace, enabled=True) + .select_related("analysis_preset") + .order_by("-precedence", "name") ) if request.method == "POST": From 1558c41769eadaeb19c19e3df42049d36bde1cda Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 08:32:21 +0200 Subject: [PATCH 66/90] rework the analysis rendering Signed-off-by: tdruez --- .../tabs/tab_packages_vulnerabilities.html | 74 +++++++++++-------- product_portfolio/tests/test_views.py | 25 ++++--- product_portfolio/views.py | 52 ++++++------- 3 files changed, 82 insertions(+), 69 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index d7332d23..fce09575 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -102,37 +102,49 @@ {% endif %} - {% if vulnerability.vulnerability_analysis %} - {% if vulnerability.vulnerability_analysis.state %} - {{ vulnerability.vulnerability_analysis.get_state_display }} - {% endif %} - {% if vulnerability.vulnerability_analysis.detail %} - - - + {% with analysis=vulnerability.vulnerability_analysis %} + {% if analysis %} +
+ {% if analysis.state %} + + {{ analysis.get_state_display }} + + {% endif %} + {% if analysis.is_reachable %} + + {% elif analysis.is_reachable is False %} + + {% endif %} +
+ {% if analysis.justification %} +
{{ analysis.get_justification_display }}
+ {% endif %} + {% if analysis.detail %} +
+ {{ analysis.detail|truncatechars:70 }} +
+ {% endif %} + {% if analysis.response_labels %} +
+ {% for response in analysis.response_labels %} + {{ response }} + {% endfor %} +
+ {% endif %} + {% if analysis.applied_by_preset %} +
+ {% trans "Auto-applied:" %} {{ analysis.applied_by_preset.name }} +
+ {% elif analysis.last_modified_by %} +
+ {{ analysis.last_modified_by }} + · {{ analysis.last_updated|date:"M j, Y" }} +
+ {% endif %} + {% else %} + {% trans "Not analyzed" %} {% endif %} - {% endif %} - - - {% if vulnerability.vulnerability_analysis.justification %} - {{ vulnerability.vulnerability_analysis.get_justification_display }} - {% endif %} - - - {% if vulnerability.vulnerability_analysis.responses %} -
    - {% for response in vulnerability.vulnerability_analysis.responses %} -
  • {{ response }}
  • - {% endfor %} -
- {% endif %} - - - {% if vulnerability.vulnerability_analysis.is_reachable %} - - {% elif vulnerability.vulnerability_analysis.is_reachable is False %} - - {% endif %} + {% endwith %} - + No results. {% if filterset.is_active %} diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index f5912a3b..88093728 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -419,18 +419,19 @@ def test_product_portfolio_tab_vulnerability_view_analysis_rendering(self): expected = """ - Resolved - - - - - Code Not Present - -
    -
  • can_not_fix
  • -
  • rollback
  • -
+
+ Resolved +
+
Code Not Present
+
+ detail +
+
+ Can Not Fix + Rollback +
""" self.assertContains(response, expected, html=True) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 497c637b..1e5b5ad5 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -174,6 +174,16 @@ "fa-exclamation-circle", ) +ANALYSIS_STATE_STYLES = { + "exploitable": "bg-danger-subtle text-danger-emphasis", + "in_triage": "bg-warning-subtle text-warning-emphasis", + "resolved": "bg-success-subtle text-success-emphasis", + "resolved_with_pedigree": "bg-success-subtle text-success-emphasis", + "not_affected": "bg-secondary-subtle text-secondary-emphasis", + "false_positive": "bg-secondary-subtle text-secondary-emphasis", +} +ANALYSIS_STATE_DEFAULT_STYLE = "bg-secondary-subtle text-secondary-emphasis" + class BaseProductViewMixin: model = Product @@ -1252,43 +1262,30 @@ class ProductTabVulnerabilitiesView( ), Header( "vulnerability_analyses__state", - _("Status"), - help_text=_("Exploitability analysis status"), - filter="vulnerability_analyses__state", - ), - Header( - "vulnerability_analyses__justification", - _("Justification"), - help_text=_("The rationale of why the impact analysis state was asserted."), - filter="vulnerability_analyses__justification", - ), - Header( - "vulnerability_analyses__responses", - _("Responses"), + _("Analysis"), help_text=_( - "A response to the vulnerability by the manufacturer, supplier, or project " - "responsible for the affected component or service." + "Exploitability analysis: status, justification, responses and reachability." ), - filter="responses", - ), - Header( - "vulnerability_analyses__is_reachable", - _("Reach"), - help_text=_( - "Indicates whether the vulnerability is reachable in the context of " - "this product package." - ), - filter="is_reachable", + filter="vulnerability_analyses__state", ), ) def attach_vulnerability_analyses(self, page_obj): """Set the matching VulnerabilityAnalysis instance on each prefetched vulnerability.""" + response_labels = dict(VulnerabilityAnalysis.Response.choices) + for product_package in page_obj.object_list: for vulnerability in product_package.package.affected_by_vulnerabilities.all(): for analysis in vulnerability.vulnerability_analyses.all(): if analysis.product_package_id == product_package.id: vulnerability.vulnerability_analysis = analysis + analysis.state_badge_class = ANALYSIS_STATE_STYLES.get( + analysis.state, ANALYSIS_STATE_DEFAULT_STYLE + ) + analysis.response_labels = [ + response_labels.get(response, response) + for response in analysis.responses or [] + ] break REACHABILITY_FILTER_MAP = {"yes": True, "no": False, "unknown": None} @@ -1384,8 +1381,11 @@ def get_context_data(self, **kwargs): risk_threshold = product.get_vulnerabilities_risk_threshold() base_productpackage_qs = product.get_vulnerable_productpackages(risk_threshold) + vulnerability_analyses_qs = VulnerabilityAnalysis.objects.select_related( + "last_modified_by", "applied_by_preset" + ) vulnerability_qs = Vulnerability.objects.prefetch_related( - "vulnerability_analyses" + Prefetch("vulnerability_analyses", queryset=vulnerability_analyses_qs) ).order_by(F("risk_score").desc(nulls_last=True)) package_qs = ( Package.objects.all() From af8df341f2ceadd6bfed328874a473a2ffd972b5 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 08:42:57 +0200 Subject: [PATCH 67/90] rework the action buttons Signed-off-by: tdruez --- .../tabs/tab_packages_vulnerabilities.html | 62 +++++++++++-------- product_portfolio/tests/test_views.py | 4 +- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index fce09575..88cb6264 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -147,34 +147,42 @@ {% endwith %} - - - - {% if analysis_presets and not vulnerability.vulnerability_analysis %} - - - +
+ + {% if vulnerability.vulnerability_analysis %} + + {% else %} + + {% endif %} - {% endif %} + {% if analysis_presets and not vulnerability.vulnerability_analysis %} + + + + + {% endif %} +
{% if not forloop.first %}{% endif %} {% endfor %} diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 88093728..a9213b8a 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -348,8 +348,8 @@ def test_product_portfolio_tab_vulnerability_view_packages_row_rendering(self): data-package-identifier="{p1}" data-edit-url="/products/vulnerability_analysis/{pp1.uuid}/{vulnerability1.advisory_uid}/" > -
From 78c897d71697ad01f44d663884ac985c8c3bc6f1 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 09:03:45 +0200 Subject: [PATCH 68/90] rework the vulnerability data popover Signed-off-by: tdruez --- .../includes/vulnerability_info_popover.html | 34 +++++++++++++++++++ dejacode/static/css/dejacode_bootstrap.css | 4 +++ .../tabs/tab_packages_vulnerabilities.html | 9 ++--- 3 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html diff --git a/component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html b/component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html new file mode 100644 index 00000000..667c2086 --- /dev/null +++ b/component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html @@ -0,0 +1,34 @@ +
+ {% if vulnerability.aliases %} +
+ Aliases +
+ {% for alias in vulnerability.aliases %} +
+ {% if alias|slice:':3' == 'CVE' %} + {{ alias }} + {% elif alias|slice:':4' == 'GHSA' %} + {{ alias }} + {% elif alias|slice:':3' == 'NPM' %} + {{ alias }} + {% else %} + {{ alias }} + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + {% if vulnerability.summary %} +
+ Summary +
{{ vulnerability.summary|linebreaksbr }}
+
+ {% endif %} + {% if vulnerability.exploitability %} +
Exploitability: {{ vulnerability.get_exploitability_display }}
+ {% endif %} + {% if vulnerability.risk_score %} +
Risk: {{ vulnerability.risk_score }}
+ {% endif %} +
diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index 01b1c7e9..89b3630e 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -1075,3 +1075,7 @@ select.bootstrap-select-filter { position: absolute; z-index: 1; } +.popover.wide-popover { + --bs-popover-max-width: 420px; + max-width: 420px; +} diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index 88cb6264..e462061f 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -48,8 +48,8 @@ {% else %} {{ vulnerability.advisory_id }} {% endif %} - {% if vulnerability.summary %} - + {% if vulnerability.summary or vulnerability.aliases or vulnerability.exploitability or vulnerability.risk_score %} + {% endif %} @@ -68,11 +68,6 @@ {% endif %} - {% if vulnerability.aliases %} -
- {% include 'component_catalog/includes/vulnerability_aliases.html' with aliases=vulnerability.aliases only %} -
- {% endif %} {% if has_triage_rulesets %} From bc2c3c38ae2d154dde5cd463ecbd02daa67aeb9e Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 09:10:30 +0200 Subject: [PATCH 69/90] refine UI Signed-off-by: tdruez --- dejacode/static/css/dejacode_bootstrap.css | 2 +- .../product_portfolio/tabs/tab_packages_vulnerabilities.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index 89b3630e..9d4c4bb6 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -414,7 +414,7 @@ table.vulnerabilities-table .column-summary { /* -- Vulnerability tab -- */ #tab_vulnerabilities .column-advisory_uid { - width: 230px; + width: 240px; } #tab_vulnerabilities .column-affected_packages { min-width: 250px; diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index e462061f..bff22be7 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -49,8 +49,8 @@ {{ vulnerability.advisory_id }} {% endif %} {% if vulnerability.summary or vulnerability.aliases or vulnerability.exploitability or vulnerability.risk_score %} - - + + {% endif %} From 95fa3910a9dc2214fa9f9523ea423aa0f57e17f7 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 09:17:05 +0200 Subject: [PATCH 70/90] fix created and modified logic Signed-off-by: tdruez --- .../product_portfolio/tabs/tab_packages_vulnerabilities.html | 4 ++-- product_portfolio/views.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index bff22be7..0783fc7b 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -130,9 +130,9 @@
{% trans "Auto-applied:" %} {{ analysis.applied_by_preset.name }}
- {% elif analysis.last_modified_by %} + {% elif analysis.last_modified_by or analysis.created_by %}
- {{ analysis.last_modified_by }} + {{ analysis.last_modified_by|default:analysis.created_by }} · {{ analysis.last_updated|date:"M j, Y" }}
{% endif %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 1e5b5ad5..5eceb615 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -1382,7 +1382,7 @@ def get_context_data(self, **kwargs): base_productpackage_qs = product.get_vulnerable_productpackages(risk_threshold) vulnerability_analyses_qs = VulnerabilityAnalysis.objects.select_related( - "last_modified_by", "applied_by_preset" + "created_by", "last_modified_by", "applied_by_preset" ) vulnerability_qs = Vulnerability.objects.prefetch_related( Prefetch("vulnerability_analyses", queryset=vulnerability_analyses_qs) From 150218ef876a2746baee924d486a48ef7094a1d5 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 09:29:20 +0200 Subject: [PATCH 71/90] fix CSS Signed-off-by: tdruez --- dejacode/static/css/dejacode_bootstrap.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index 9d4c4bb6..b4509aac 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -417,11 +417,15 @@ table.vulnerabilities-table .column-summary { width: 240px; } #tab_vulnerabilities .column-affected_packages { - min-width: 250px; + min-width: 300px; + width: 300px; } #tab_vulnerabilities .column-triage_action { min-width: 165px; } +#tab_vulnerabilities .column-action { + width: 30px; +} #tab_vulnerabilities .column-exploitability { width: 140px; } From 5471f7c92ef50b67ba7e6be830dca527fc0540fe Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 09:37:57 +0200 Subject: [PATCH 72/90] refine UI Signed-off-by: tdruez --- dejacode/static/css/dejacode_bootstrap.css | 3 + .../tabs/tab_packages_vulnerabilities.html | 84 ++++++++++--------- product_portfolio/tests/test_views.py | 30 ++++--- 3 files changed, 64 insertions(+), 53 deletions(-) diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index b4509aac..780ab914 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -1083,3 +1083,6 @@ select.bootstrap-select-filter { --bs-popover-max-width: 420px; max-width: 420px; } +.stretch-cell { + height: 1px; +} diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index 0783fc7b..b3978e83 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -96,49 +96,53 @@ {% endif %} {% endif %} - + {% with analysis=vulnerability.vulnerability_analysis %} - {% if analysis %} -
- {% if analysis.state %} - - {{ analysis.get_state_display }} - - {% endif %} - {% if analysis.is_reachable %} - - {% elif analysis.is_reachable is False %} - - {% endif %} -
- {% if analysis.justification %} -
{{ analysis.get_justification_display }}
- {% endif %} - {% if analysis.detail %} -
- {{ analysis.detail|truncatechars:70 }} -
- {% endif %} - {% if analysis.response_labels %} -
- {% for response in analysis.response_labels %} - {{ response }} - {% endfor %} -
- {% endif %} - {% if analysis.applied_by_preset %} -
- {% trans "Auto-applied:" %} {{ analysis.applied_by_preset.name }} -
- {% elif analysis.last_modified_by or analysis.created_by %} -
- {{ analysis.last_modified_by|default:analysis.created_by }} - · {{ analysis.last_updated|date:"M j, Y" }} +
+ {% if analysis %} +
+
+ {% if analysis.state %} + + {{ analysis.get_state_display }} + + {% endif %} + {% if analysis.is_reachable %} + + {% elif analysis.is_reachable is False %} + + {% endif %} +
+ {% if analysis.justification %} +
{{ analysis.get_justification_display }}
+ {% endif %} + {% if analysis.detail %} +
+ {{ analysis.detail|truncatechars:70 }} +
+ {% endif %} + {% if analysis.response_labels %} +
+ {% for response in analysis.response_labels %} + {{ response }} + {% endfor %} +
+ {% endif %}
+ {% if analysis.applied_by_preset %} +
+ {% trans "Auto-applied:" %} {{ analysis.applied_by_preset.name }} +
+ {% elif analysis.last_modified_by or analysis.created_by %} +
+ {{ analysis.last_modified_by|default:analysis.created_by }} + · {{ analysis.last_updated|date:"M j, Y" }} +
+ {% endif %} + {% else %} + {% trans "Not analyzed" %} {% endif %} - {% else %} - {% trans "Not analyzed" %} - {% endif %} +
{% endwith %} diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index a9213b8a..a8847095 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -418,19 +418,23 @@ def test_product_portfolio_tab_vulnerability_view_analysis_rendering(self): response = self.client.get(url) expected = """ - -
- Resolved -
-
Code Not Present
-
- detail -
-
- Can Not Fix - Rollback + +
+
+
+ Resolved +
+
Code Not Present
+
+ detail +
+
+ Can Not Fix + Rollback +
+
""" From 69a9a5fbebd0e36fe9b9863f6d13e6270fb31c64 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 10:11:58 +0200 Subject: [PATCH 73/90] fix test Signed-off-by: tdruez --- product_portfolio/tests/test_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index a8847095..73170f47 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -420,13 +420,13 @@ def test_product_portfolio_tab_vulnerability_view_analysis_rendering(self): expected = """
-
+
Resolved
Code Not Present
detail
From 515c71077ebaa36e7910fa492e0548813d19a31b Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 10:20:01 +0200 Subject: [PATCH 74/90] fix filtering Signed-off-by: tdruez --- product_portfolio/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 5eceb615..560c1a57 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -1306,7 +1306,7 @@ def vulnerability_passes_display_filters(self, vulnerability, display_filters): triage_action = display_filters.get("triage_action") if triage_action: record = getattr(vulnerability, "triage_record", None) - if getattr(record, "action", "") != triage_action: + if getattr(record, "recommended_action", "") != triage_action: return False analysis = getattr(vulnerability, "vulnerability_analysis", None) state = display_filters.get("state") From f364104695ac13fc64a34d3f2627e0b4382f01b7 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 10:25:47 +0200 Subject: [PATCH 75/90] handle the validation error in admin Signed-off-by: tdruez --- vulnerabilities/triage/forms.py | 8 ++++++++ vulnerabilities/triage/tests/test_forms.py | 23 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py index 7fcbf660..67ee0499 100644 --- a/vulnerabilities/triage/forms.py +++ b/vulnerabilities/triage/forms.py @@ -7,6 +7,7 @@ # from django import forms +from django.core.exceptions import ValidationError from dje.forms import DataspacedAdminForm from vulnerabilities.models import VulnerabilityAnalysisContentMixin @@ -35,6 +36,13 @@ class Meta: "is_reachable", ] + def clean(self): + main_fields = ["state", "justification", "responses", "detail"] + if not any(self.cleaned_data.get(field_name) for field_name in main_fields): + raise ValidationError( + "At least one of state, justification, responses or detail must be provided." + ) + class TriageRulesetForm(DataspacedAdminForm): recommended_action = forms.ChoiceField( diff --git a/vulnerabilities/triage/tests/test_forms.py b/vulnerabilities/triage/tests/test_forms.py index c8b7e032..b06fbeea 100644 --- a/vulnerabilities/triage/tests/test_forms.py +++ b/vulnerabilities/triage/tests/test_forms.py @@ -9,10 +9,33 @@ from django.test import TestCase from dje.models import Dataspace +from vulnerabilities.triage.forms import AnalysisPresetForm from vulnerabilities.triage.forms import TriageRulesetForm +from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import TriageRuleset +class AnalysisPresetFormTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + + def test_rejects_a_preset_with_no_content_field_set(self): + # Mirrors VulnerabilityAnalysisForm.clean: is_reachable alone is not enough content + # to apply, and must be caught here rather than crash in AnalysisPreset.save(). + data = {"name": "No content", "is_reachable": True} + form = AnalysisPresetForm(data=data, instance=AnalysisPreset(dataspace=self.dataspace)) + self.assertFalse(form.is_valid()) + msg = "At least one of state, justification, responses or detail must be provided." + self.assertEqual({"__all__": [msg]}, form.errors) + + def test_accepts_a_preset_with_detail_only(self): + data = {"name": "Detail only", "detail": "Some detail"} + form = AnalysisPresetForm(data=data, instance=AnalysisPreset(dataspace=self.dataspace)) + self.assertTrue(form.is_valid(), form.errors) + preset = form.save() + self.assertEqual("Some detail", preset.detail) + + class TriageRulesetFormTestCase(TestCase): def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") From c67b17f33074e20687fc924aa398bebb529eeb60 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 14:36:42 +0200 Subject: [PATCH 76/90] add link to manage rules Signed-off-by: tdruez --- dje/templates/tabs/pagination.html | 3 +++ .../includes/manage_triage_rules_nav_item.html | 6 ++++++ .../tabs/tab_packages_vulnerabilities.html | 2 +- product_portfolio/views.py | 10 ++++++++++ 4 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 product_portfolio/templates/product_portfolio/includes/manage_triage_rules_nav_item.html diff --git a/dje/templates/tabs/pagination.html b/dje/templates/tabs/pagination.html index 7f7b65a8..c1cf18d0 100644 --- a/dje/templates/tabs/pagination.html +++ b/dje/templates/tabs/pagination.html @@ -19,6 +19,9 @@ {% endif %}
+ {% if extra_nav_item_template %} + {% include extra_nav_item_template %} + {% endif %}
{% include 'includes/filters_breadcrumbs.html' with filterset=filterset fragment=tab_id only %} diff --git a/product_portfolio/templates/product_portfolio/includes/manage_triage_rules_nav_item.html b/product_portfolio/templates/product_portfolio/includes/manage_triage_rules_nav_item.html new file mode 100644 index 00000000..39b21042 --- /dev/null +++ b/product_portfolio/templates/product_portfolio/includes/manage_triage_rules_nav_item.html @@ -0,0 +1,6 @@ +{% load i18n %} + diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index b3978e83..55b3b8c5 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -1,7 +1,7 @@ {% load i18n %} {% load as_icon from dje_tags %} -{% include 'tabs/pagination.html' with search_verbose_name="packages" %} +{% include 'tabs/pagination.html' with search_verbose_name="packages" extra_nav_item_template=manage_triage_rules_nav_item_template %} {% if risk_threshold %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 560c1a57..b4bae4d0 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -1432,6 +1432,15 @@ def get_context_data(self, **kwargs): self.attach_triage_data(product, page_obj) analysis_presets = list(AnalysisPreset.objects.scope(product.dataspace)) + has_change_permission = "change_product" in guardian_get_perms(self.request.user, product) + can_manage_triage_rules = ( + has_change_permission and self.request.user.dataspace.enable_vulnerablecodedb_access + ) + manage_triage_rules_nav_item_template = None + if can_manage_triage_rules: + manage_triage_rules_nav_item_template = ( + "product_portfolio/includes/manage_triage_rules_nav_item.html" + ) context_data.update( { "filterset": self.filterset, @@ -1441,6 +1450,7 @@ def get_context_data(self, **kwargs): "risk_threshold": risk_threshold, "has_triage_rulesets": self.has_triage_rulesets, "analysis_presets": analysis_presets, + "manage_triage_rules_nav_item_template": manage_triage_rules_nav_item_template, } ) From 97f050a2f879bc19a3ba398aaba6ca589de8af38 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 15:43:51 +0200 Subject: [PATCH 77/90] adjust default timeout Signed-off-by: tdruez --- dje/cron_jobs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dje/cron_jobs.py b/dje/cron_jobs.py index 45a802a2..1316c0bd 100644 --- a/dje/cron_jobs.py +++ b/dje/cron_jobs.py @@ -28,12 +28,12 @@ func=evaluate_all_products_rules_task, queue_name="default", cron=settings.DEJACODE_POLICY_RULES_CRON, # Hourly by default - job_timeout=ten_minutes, + job_timeout=two_hours, ) cron.register( func=evaluate_all_products_vulnerability_triage_task, queue_name="default", cron=settings.DEJACODE_VULNERABILITY_TRIAGE_CRON, # Hourly by default - job_timeout=two_hours, + job_timeout=ten_minutes, ) From f91d72c53952fefab41d4f9848bd1465f7e75f4f Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 16:43:00 +0200 Subject: [PATCH 78/90] rework part of the code Signed-off-by: tdruez --- product_portfolio/filters.py | 15 +-------------- .../tabs/tab_packages_vulnerabilities.html | 5 +++++ product_portfolio/tests/test_views.py | 2 +- product_portfolio/views.py | 9 ++++++--- vulnerabilities/models.py | 11 ++++------- vulnerabilities/triage/engine.py | 8 +------- vulnerabilities/triage/rules.py | 8 +++++--- 7 files changed, 23 insertions(+), 35 deletions(-) diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index b863712a..5c40ff65 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -11,7 +11,6 @@ from django.db.models import Exists from django.db.models import OuterRef from django.db.models import Q -from django.db.models import Subquery from django.utils.translation import gettext_lazy as _ import django_filters @@ -452,21 +451,9 @@ class Meta: def filter_triage_action(queryset, name, value): if not value: return queryset - winning_ruleset_id = ( - TriageRecord.objects.filter( - vulnerability=OuterRef("vulnerability"), - product=OuterRef("product"), - ruleset__enabled=True, - ruleset__product_triage_rulesets__product=OuterRef("product"), - ) - .order_by("-ruleset__precedence") - .values("ruleset_id")[:1] - ) - primary_triage = TriageRecord.objects.filter( + primary_triage = TriageRecord.objects.highest_precedence().filter( product=OuterRef("product"), vulnerability__affected_packages__productpackages=OuterRef("pk"), - ruleset__enabled=True, - ruleset_id=Subquery(winning_ruleset_id), recommended_action=value, ) return queryset.filter(Exists(primary_triage)).distinct() diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index 55b3b8c5..9c886d78 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -35,6 +35,11 @@ {{ product_package.purpose.label }} {% endif %}
+ {% if product_package.purpose.exposure_factor %} +
+ {% trans "Exposure factor:" %} {{ product_package.purpose.exposure_factor }} +
+ {% endif %} {% for vulnerability in product_package.display_vulnerabilities %} {% if not forloop.first %}{% endif %} diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 73170f47..7271007f 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -296,7 +296,7 @@ def test_product_portfolio_detail_view_tab_vulnerability_queryset(self): self.assertEqual(4, product1.packages.vulnerable().count()) url = product1.get_url("tab_vulnerabilities") - with self.assertMaxQueries(15): + with self.assertMaxQueries(16): response = self.client.get(url) self.assertContains(response, "4 results") diff --git a/product_portfolio/views.py b/product_portfolio/views.py index b4bae4d0..64a59492 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -1308,21 +1308,25 @@ def vulnerability_passes_display_filters(self, vulnerability, display_filters): record = getattr(vulnerability, "triage_record", None) if getattr(record, "recommended_action", "") != triage_action: return False + analysis = getattr(vulnerability, "vulnerability_analysis", None) state = display_filters.get("state") if state: if getattr(analysis, "state", "") != state: return False + justification = display_filters.get("justification") if justification: if getattr(analysis, "justification", "") != justification: return False + is_reachable_filter = display_filters.get("is_reachable") - if is_reachable_filter: + if is_reachable_filter in self.REACHABILITY_FILTER_MAP: expected = self.REACHABILITY_FILTER_MAP[is_reachable_filter] actual = None if analysis is None else analysis.is_reachable if actual != expected: return False + return True def attach_triage_data(self, product, page_obj): @@ -2982,8 +2986,7 @@ def apply_analysis_preset_view(request, productpackage_uuid, advisory_uid, prese ) preset.apply_to_analysis(analysis) - content_fields = [analysis.state, analysis.justification, analysis.responses, analysis.detail] - if not any(content_fields): + if not analysis.has_content_fields(): return JsonResponse({"error": "This preset has no content fields to apply."}, status=400) analysis.applied_by_preset = preset diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 046e9221..8a3d89d9 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -345,15 +345,12 @@ class Response(models.TextChoices): ), ) + def has_content_fields(self): + return any([self.state, self.justification, self.responses, self.detail]) + def save(self, *args, **kwargs): # At least one of those fields must be provided. - main_fields = [ - self.state, - self.justification, - self.responses, - self.detail, - ] - if not any(main_fields): + if not self.has_content_fields(): raise ValueError( "At least one of state, justification, responses or detail must be provided." ) diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index 801bf20a..a896d985 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -101,13 +101,7 @@ def apply_preset_for_vulnerabilities(preset, product, vulnerability_ids): dataspace_id=product.dataspace_id, ) preset.apply_to_analysis(analysis) - content_fields = [ - analysis.state, - analysis.justification, - analysis.responses, - analysis.detail, - ] - if not any(content_fields): + if not analysis.has_content_fields(): continue # Preset has no content fields - cannot save a new analysis else: analysis = existing diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index 6dd7d437..0aa51286 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -15,7 +15,9 @@ from policy.rules import BaseRule -TERMINAL_VULNERABILITY_STATES = [ +# Not shared with policy.rules.TERMINAL_VULNERABILITY_STATES: the two lists are +# intentionally scoped to their own engine and are not guaranteed to stay identical. +TRIAGE_TERMINAL_VULNERABILITY_STATES = [ "resolved", "resolved_with_pedigree", "not_affected", @@ -130,7 +132,7 @@ def get_matching_vulnerabilities(self, product, parameters=None): terminal_analysis = VulnerabilityAnalysis.objects.filter( product_package=OuterRef("pk"), vulnerability=OuterRef(OuterRef("pk")), - state__in=TERMINAL_VULNERABILITY_STATES, + state__in=TRIAGE_TERMINAL_VULNERABILITY_STATES, ) # A package in the product that carries this vulnerability but has no terminal analysis unresolved_package = ProductPackage.objects.filter( @@ -186,7 +188,7 @@ def get_matching_vulnerabilities(self, product, parameters=None): terminal_analysis = VulnerabilityAnalysis.objects.filter( product_package=OuterRef("pk"), vulnerability=OuterRef(OuterRef("pk")), - state__in=TERMINAL_VULNERABILITY_STATES, + state__in=TRIAGE_TERMINAL_VULNERABILITY_STATES, ) unresolved_package = ProductPackage.objects.filter( product=product, From 034b5ad7d67e570ad2978367edca4089775170d0 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 17 Aug 2026 17:13:28 +0200 Subject: [PATCH 79/90] fix underlying bugs Signed-off-by: tdruez --- vulnerabilities/triage/forms.py | 8 +++++ .../commands/create_triage_rulesets.py | 4 --- .../management/commands/evaluate_triage.py | 4 --- vulnerabilities/triage/tests/test_forms.py | 36 +++++++++++++++++++ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py index 67ee0499..ba3a084a 100644 --- a/vulnerabilities/triage/forms.py +++ b/vulnerabilities/triage/forms.py @@ -62,6 +62,14 @@ class Meta: "request_template", ] + def clean_request_template(self): + request_template = self.cleaned_data.get("request_template") + if request_template and not request_template.created_by_id: + raise ValidationError( + "This request template has no creator and cannot be used to open requests." + ) + return request_template + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.add_rule_fields() diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index df340e81..003a7d87 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -14,10 +14,6 @@ from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRuleset -""" -docker compose -f compose.dev.yml exec web ./manage.py create_triage_rulesets nexB -""" - REFERENCE_PRESETS = [ { "name": "Auto-Close - Dev Only", diff --git a/vulnerabilities/triage/management/commands/evaluate_triage.py b/vulnerabilities/triage/management/commands/evaluate_triage.py index 3febbb87..34f078ea 100644 --- a/vulnerabilities/triage/management/commands/evaluate_triage.py +++ b/vulnerabilities/triage/management/commands/evaluate_triage.py @@ -14,10 +14,6 @@ from vulnerabilities.triage.models import ProductTriageRuleset from vulnerabilities.triage.models import TriageRecord -""" -docker compose -f compose.dev.yml exec web ./manage.py evaluate_triage nexB -""" - class Command(BaseCommand): help = "Evaluate all enabled triage rulesets against all products in the given dataspace." diff --git a/vulnerabilities/triage/tests/test_forms.py b/vulnerabilities/triage/tests/test_forms.py index b06fbeea..16935a18 100644 --- a/vulnerabilities/triage/tests/test_forms.py +++ b/vulnerabilities/triage/tests/test_forms.py @@ -6,13 +6,17 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from dje.models import Dataspace +from dje.tests import create_user +from product_portfolio.models import Product from vulnerabilities.triage.forms import AnalysisPresetForm from vulnerabilities.triage.forms import TriageRulesetForm from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import TriageRuleset +from workflow.models import RequestTemplate class AnalysisPresetFormTestCase(TestCase): @@ -84,3 +88,35 @@ def test_save_keeps_an_inactive_rule_that_has_parameters_with_their_defaults(sel self.assertEqual( {"is_active": False, "min_risk_score": 8.0}, ruleset.rules_config["risk_score"] ) + + def test_rejects_a_request_template_with_no_creator(self): + # A RequestTemplate normally always has a creator (the admin form sets it on + # addition), but nothing at the DB level guarantees it -- reject it here rather + # than let create_triage_requests crash later with an IntegrityError. + request_template = RequestTemplate.objects.create( + name="Broken Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + ) + data = {"name": "My Ruleset", "precedence": 100, "request_template": request_template.pk} + form = TriageRulesetForm(data=data, instance=TriageRuleset(dataspace=self.dataspace)) + + self.assertFalse(form.is_valid()) + + msg = "This request template has no creator and cannot be used to open requests." + self.assertEqual({"request_template": [msg]}, form.errors) + + def test_accepts_a_request_template_with_a_creator(self): + requester = create_user("requester", self.dataspace) + request_template = RequestTemplate.objects.create( + name="Valid Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=requester, + ) + data = {"name": "My Ruleset", "precedence": 100, "request_template": request_template.pk} + form = TriageRulesetForm(data=data, instance=TriageRuleset(dataspace=self.dataspace)) + + self.assertTrue(form.is_valid(), form.errors) From 463ccd23e3a2d0c3f06a67f98f853bc9d64389c0 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 18 Aug 2026 11:29:32 +0200 Subject: [PATCH 80/90] move evaluation in a signal Signed-off-by: tdruez --- product_portfolio/tests/test_views.py | 5 +-- product_portfolio/views.py | 4 +-- vulnerabilities/triage/rules.py | 2 -- vulnerabilities/triage/signals.py | 14 ++++++-- vulnerabilities/triage/tests/test_signals.py | 34 ++++++++++++++++++++ 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 7271007f..3e528e8c 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -4416,7 +4416,7 @@ def test_post_without_change_perm_returns_404(self): response = self.client.post(url) self.assertEqual(404, response.status_code) - @patch("product_portfolio.views.evaluate_ruleset") + @patch("vulnerabilities.triage.signals.evaluate_ruleset") def test_post_assigns_the_submitted_rulesets(self, mock_evaluate): self.client.login(username="nexb_user", password="secret") url = self.product1.get_manage_triage_rulesets_url() @@ -4429,11 +4429,12 @@ def test_post_assigns_the_submitted_rulesets(self, mock_evaluate): ) mock_evaluate.assert_called_once_with(ruleset=self.ruleset, product=self.product1) - @patch("product_portfolio.views.evaluate_ruleset") + @patch("vulnerabilities.triage.signals.evaluate_ruleset") def test_post_unassigns_the_deselected_rulesets(self, mock_evaluate): ProductTriageRuleset.objects.create( product=self.product1, ruleset=self.ruleset, dataspace=self.dataspace ) + mock_evaluate.reset_mock() self.client.login(username="nexb_user", password="secret") url = self.product1.get_manage_triage_rulesets_url() response = self.client.post(url, {"ruleset_uuids": []}) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 64a59492..f1d5563a 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -150,7 +150,6 @@ from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.models import get_risk_level -from vulnerabilities.triage.engine import evaluate_ruleset from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import ProductTriageRuleset from vulnerabilities.triage.models import TriageAction @@ -2253,13 +2252,14 @@ def manage_triage_rulesets_view(request, dataspace, name, version=""): for ruleset in available_rulesets: ruleset_uuid = str(ruleset.uuid) if ruleset_uuid in submitted_uuids and ruleset_uuid not in current_assignments: + # The evaluate_on_assign signal evaluates the ruleset against the product; + # wrapping in atomic() rolls the assignment back if that evaluation fails. with transaction.atomic(): ProductTriageRuleset.objects.create( product=product, ruleset=ruleset, dataspace=product.dataspace, ) - evaluate_ruleset(ruleset=ruleset, product=product) for ruleset_uuid, assignment in current_assignments.items(): if ruleset_uuid not in submitted_uuids: assignment.delete() diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index 0aa51286..01f45343 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -15,8 +15,6 @@ from policy.rules import BaseRule -# Not shared with policy.rules.TERMINAL_VULNERABILITY_STATES: the two lists are -# intentionally scoped to their own engine and are not guaranteed to stay identical. TRIAGE_TERMINAL_VULNERABILITY_STATES = [ "resolved", "resolved_with_pedigree", diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index 7db616ab..70063990 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -42,6 +42,15 @@ def reevaluate_or_delete_on_ruleset_save(sender, instance, created, **kwargs): evaluate_ruleset(ruleset=instance, product=assignment.product) +@receiver(post_save, sender="vulnerabilities_triage.ProductTriageRuleset") +def evaluate_on_assign(sender, instance, created, **kwargs): + """Evaluate the ruleset against the product as soon as it is assigned.""" + if not created or not instance.ruleset.enabled: + return + + evaluate_ruleset(ruleset=instance.ruleset, product=instance.product) + + @receiver(post_delete, sender="vulnerabilities_triage.ProductTriageRuleset") def delete_triage_records_on_unassign(sender, instance, **kwargs): """Delete triage records and associated preset analyses when a ruleset is de-assigned.""" @@ -66,8 +75,9 @@ def reevaluate_on_analysis_change(sender, instance, **kwargs): """Re-evaluate triage when an analysis state or reachability is updated.""" signal = kwargs.get("signal") if signal == post_save and instance.applied_by_preset_id: - return # Written by the triage engine itself -- re-evaluating would loop - # When a human explicitly deletes their analysis, skip preset application to avoid + return # Written by the triage engine itself, re-evaluating would loop + + # When an user explicitly deletes their analysis, skip preset application to avoid # having the engine immediately recreate it. is_human_delete = signal == post_delete and not instance.applied_by_preset_id reevaluate_product_rulesets( diff --git a/vulnerabilities/triage/tests/test_signals.py b/vulnerabilities/triage/tests/test_signals.py index 989f978b..e180fddd 100644 --- a/vulnerabilities/triage/tests/test_signals.py +++ b/vulnerabilities/triage/tests/test_signals.py @@ -42,6 +42,7 @@ def setUp(self): def test_evaluates_every_enabled_ruleset_assigned_to_the_product(self, mock_evaluate): ruleset = make_triage_ruleset(self.dataspace, enabled=True) make_product_triage_ruleset(self.product, ruleset=ruleset) + mock_evaluate.reset_mock() # Called once already by the evaluate_on_assign signal. reevaluate_product_rulesets(self.product) @@ -62,6 +63,7 @@ def test_skips_disabled_ruleset_assignments(self, mock_evaluate): def test_apply_preset_flag_is_forwarded(self, mock_evaluate): ruleset = make_triage_ruleset(self.dataspace, enabled=True) make_product_triage_ruleset(self.product, ruleset=ruleset) + mock_evaluate.reset_mock() # Called once already by the evaluate_on_assign signal. reevaluate_product_rulesets(self.product, apply_preset=False) @@ -196,6 +198,38 @@ def test_disabling_then_reenabling_a_ruleset_reuses_the_existing_request(self): self.assertEqual(original_request, TriageRecord.objects.get().request) +class EvaluateOnAssignSignalTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + @patch("vulnerabilities.triage.signals.evaluate_ruleset") + def test_assigning_an_enabled_ruleset_evaluates_it(self, mock_evaluate): + ruleset = make_triage_ruleset(self.dataspace, enabled=True) + + make_product_triage_ruleset(self.product, ruleset=ruleset) + + mock_evaluate.assert_called_once_with(ruleset=ruleset, product=self.product) + + @patch("vulnerabilities.triage.signals.evaluate_ruleset") + def test_assigning_a_disabled_ruleset_does_not_evaluate(self, mock_evaluate): + ruleset = make_triage_ruleset(self.dataspace, enabled=False) + + make_product_triage_ruleset(self.product, ruleset=ruleset) + + mock_evaluate.assert_not_called() + + @patch("vulnerabilities.triage.signals.evaluate_ruleset") + def test_resaving_an_existing_assignment_does_not_reevaluate(self, mock_evaluate): + ruleset = make_triage_ruleset(self.dataspace, enabled=True) + assignment = make_product_triage_ruleset(self.product, ruleset=ruleset) + mock_evaluate.reset_mock() + + assignment.save() + + mock_evaluate.assert_not_called() + + class DeleteTriageRecordsOnUnassignSignalTestCase(TestCase): def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") From 1515fd5a1f2b020e70a843e1e2f9fb91587e83f9 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 18 Aug 2026 12:12:45 +0200 Subject: [PATCH 81/90] refactor signal Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 16 ++++ vulnerabilities/triage/engine.py | 11 +++ .../commands/create_triage_rulesets.py | 93 +++++++++++-------- vulnerabilities/triage/signals.py | 28 +----- 4 files changed, 82 insertions(+), 66 deletions(-) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 57e1a714..16ab2f75 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -13,6 +13,7 @@ from dje.admin import DataspacedAdmin from dje.admin import dejacode_site from dje.list_display import AsLink +from vulnerabilities.triage.engine import evaluate_ruleset from vulnerabilities.triage.forms import AnalysisPresetForm from vulnerabilities.triage.forms import TriageRulesetForm from vulnerabilities.triage.models import AnalysisPreset @@ -130,6 +131,21 @@ def get_changes_details(self, form): ] return super().get_changes_details(form) + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + + if not obj.enabled: + # Records with an open Request are kept so re-enabling the ruleset reconnects to + # it instead of opening a duplicate Request. + obj.triage_records.filter(request__isnull=True).delete() + return + + if not change: + return + + for assignment in obj.product_triage_rulesets.select_related("product"): + evaluate_ruleset(ruleset=obj, product=assignment.product) + def get_form(self, request, obj=None, change=False, **kwargs): kwargs["fields"] = [ "name", diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index a896d985..34c11fa4 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -9,6 +9,7 @@ from django.apps import apps from django.utils import timezone +from vulnerabilities.triage.models import ProductTriageRuleset from vulnerabilities.triage.models import TriageRecord from vulnerabilities.triage.rules import RULE_REGISTRY from vulnerabilities.triage.rules import rule_parameters_from_config @@ -225,3 +226,13 @@ def evaluate_ruleset(ruleset, product, apply_preset=True): matched_rules_per_vulnerability_id=matched_rules_per_vulnerability_id, apply_preset=apply_preset, ) + + +def reevaluate_product_rulesets(product, apply_preset=True): + """Re-evaluate all enabled triage rulesets currently assigned to the product.""" + assignments = ProductTriageRuleset.objects.filter( + product=product, ruleset__enabled=True + ).select_related("ruleset", "ruleset__analysis_preset") + + for assignment in assignments: + evaluate_ruleset(ruleset=assignment.ruleset, product=product, apply_preset=apply_preset) diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index 003a7d87..bd3e2e2f 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -139,9 +139,18 @@ def add_arguments(self, parser): action="store_true", help=( "Delete all existing triage rulesets in the dataspace before recreating them." - " This also removes all associated product assignments and triage records." + " This also removes all associated product assignments and triage records," + " and every product using these rulesets will need to be reassigned manually" + " afterward." ), ) + parser.add_argument( + "--noinput", + "--no-input", + action="store_false", + dest="interactive", + help="Tells Django to NOT prompt the user for input of any kind.", + ) def handle(self, *args, **options): dataspace_name = options["dataspace"] @@ -152,62 +161,68 @@ def handle(self, *args, **options): raise CommandError(f'Dataspace "{dataspace_name}" does not exist.') if options["reset"]: + if options["interactive"]: + confirm = input( + "You have requested a reset of the triage rulesets in dataspace" + f' "{dataspace_name}".\n' + "This will delete all triage rulesets and analysis presets in this" + " dataspace, along with every associated product assignment and triage" + " record. Every product using these rulesets will need to be reassigned" + " manually afterward.\n" + "Are you sure you want to do this?\n\n" + " Type 'yes' to continue, or 'no' to cancel: " + ) + else: + confirm = "yes" + + if confirm != "yes": + self.stdout.write("Reset cancelled.") + return + deleted_rulesets, _ = TriageRuleset.objects.filter(dataspace=dataspace).delete() deleted_presets, _ = AnalysisPreset.objects.filter(dataspace=dataspace).delete() self.stdout.write( f" Deleted {deleted_rulesets} existing ruleset(s) and {deleted_presets} preset(s)." ) + elif TriageRuleset.objects.filter(dataspace=dataspace).exists(): + raise CommandError( + f'Dataspace "{dataspace_name}" already has triage rulesets.' + " Use --reset to delete and recreate them." + ) - ruleset_created_count = 0 for ruleset_data in REFERENCE_RULESETS: - _, created = TriageRuleset.objects.get_or_create( + TriageRuleset.objects.create( dataspace=dataspace, name=ruleset_data["name"], - defaults={ - "description": ruleset_data["description"], - "recommended_action": ruleset_data["recommended_action"], - "precedence": ruleset_data["precedence"], - "rules_config": ruleset_data["rules_config"], - "enabled": True, - }, + description=ruleset_data["description"], + recommended_action=ruleset_data["recommended_action"], + precedence=ruleset_data["precedence"], + rules_config=ruleset_data["rules_config"], + enabled=True, ) - if created: - ruleset_created_count += 1 - self.stdout.write(f" Created: {ruleset_data['name']}") - else: - self.stdout.write(f" Already exists: {ruleset_data['name']}") + self.stdout.write(f" Created: {ruleset_data['name']}") - preset_created_count = 0 for preset_data in REFERENCE_PRESETS: - ruleset_name = preset_data["ruleset_name"] - preset, preset_created = AnalysisPreset.objects.get_or_create( + preset = AnalysisPreset.objects.create( dataspace=dataspace, name=preset_data["name"], - defaults={ - "description": preset_data.get("description", ""), - "state": preset_data.get("state", ""), - "justification": preset_data.get("justification", ""), - "responses": preset_data.get("responses"), - "detail": preset_data.get("detail", ""), - }, + description=preset_data.get("description", ""), + state=preset_data.get("state", ""), + justification=preset_data.get("justification", ""), + responses=preset_data.get("responses"), + detail=preset_data.get("detail", ""), ) - if preset_created: - preset_created_count += 1 - self.stdout.write(f" Created preset: {preset_data['name']}") - else: - self.stdout.write(f" Already exists: {preset_data['name']}") - try: - ruleset = TriageRuleset.objects.get(dataspace=dataspace, name=ruleset_name) - if ruleset.analysis_preset_id != preset.pk: - ruleset.analysis_preset = preset - ruleset.save(update_fields=["analysis_preset"]) - self.stdout.write(f" Linked preset to ruleset: {ruleset_name}") - except TriageRuleset.DoesNotExist: - self.stdout.write(f" Ruleset not found: {ruleset_name}") + self.stdout.write(f" Created preset: {preset_data['name']}") + + ruleset_name = preset_data["ruleset_name"] + ruleset = TriageRuleset.objects.get(dataspace=dataspace, name=ruleset_name) + ruleset.analysis_preset = preset + ruleset.save(update_fields=["analysis_preset"]) + self.stdout.write(f" Linked preset to ruleset: {ruleset_name}") self.stdout.write( self.style.SUCCESS( - f"{ruleset_created_count} ruleset(s) and {preset_created_count} preset(s)" + f"{len(REFERENCE_RULESETS)} ruleset(s) and {len(REFERENCE_PRESETS)} preset(s)" f" created in dataspace '{dataspace_name}'." ) ) diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index 70063990..d05b5181 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -12,36 +12,10 @@ from vulnerabilities.triage.engine import delete_preset_analyses_for_product from vulnerabilities.triage.engine import evaluate_ruleset -from vulnerabilities.triage.models import ProductTriageRuleset +from vulnerabilities.triage.engine import reevaluate_product_rulesets from vulnerabilities.triage.models import TriageRecord -def reevaluate_product_rulesets(product, apply_preset=True): - """Re-evaluate all enabled triage rulesets currently assigned to the product.""" - assignments = ProductTriageRuleset.objects.filter( - product=product, ruleset__enabled=True - ).select_related("ruleset", "ruleset__analysis_preset") - - for assignment in assignments: - evaluate_ruleset(ruleset=assignment.ruleset, product=product, apply_preset=apply_preset) - - -@receiver(post_save, sender="vulnerabilities_triage.TriageRuleset") -def reevaluate_or_delete_on_ruleset_save(sender, instance, created, **kwargs): - """Re-evaluate assigned products on config change; delete records when disabled.""" - if not instance.enabled: - # Records with an open Request are kept so re-enabling the ruleset reconnects to it - # instead of opening a duplicate Request. - instance.triage_records.filter(request__isnull=True).delete() - return - - if created: - return - - for assignment in instance.product_triage_rulesets.select_related("product"): - evaluate_ruleset(ruleset=instance, product=assignment.product) - - @receiver(post_save, sender="vulnerabilities_triage.ProductTriageRuleset") def evaluate_on_assign(sender, instance, created, **kwargs): """Evaluate the ruleset against the product as soon as it is assigned.""" From 85ebf0749aa027868c26fd257fd7b5e7344a47b6 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 18 Aug 2026 12:23:52 +0200 Subject: [PATCH 82/90] refine signal Signed-off-by: tdruez --- vulnerabilities/triage/signals.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index d05b5181..91818e84 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -46,17 +46,18 @@ def delete_triage_records_on_unassign(sender, instance, **kwargs): @receiver([post_save, post_delete], sender="vulnerabilities.VulnerabilityAnalysis") def reevaluate_on_analysis_change(sender, instance, **kwargs): - """Re-evaluate triage when an analysis state or reachability is updated.""" + """Re-evaluate triage on a product when an vulnerability analysis is updated.""" signal = kwargs.get("signal") if signal == post_save and instance.applied_by_preset_id: - return # Written by the triage engine itself, re-evaluating would loop + # When the analysis is created by the triage engine itself, the evaluation is skipped. + return - # When an user explicitly deletes their analysis, skip preset application to avoid + # When a user explicitly deletes their analysis, skip preset application to avoid # having the engine immediately recreate it. - is_human_delete = signal == post_delete and not instance.applied_by_preset_id + is_user_delete = signal == post_delete and not instance.applied_by_preset_id reevaluate_product_rulesets( instance.product_package.product, - apply_preset=not is_human_delete, + apply_preset=not is_user_delete, ) From f716f9a160fdf2c52d36efbadd2367606cd6a13b Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 18 Aug 2026 12:33:11 +0200 Subject: [PATCH 83/90] merge signals Signed-off-by: tdruez --- policy/signals.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/policy/signals.py b/policy/signals.py index 4734945b..d04d2d7c 100644 --- a/policy/signals.py +++ b/policy/signals.py @@ -26,15 +26,9 @@ def evaluate_product_rules_on_product_save(sender, instance, **kwargs): 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.""" +@receiver([post_save, post_delete], sender="product_portfolio.ProductPackage") +def evaluate_product_rules_on_productpackage_change(sender, instance, **kwargs): + """Queue a policy rule evaluation whenever a package is added, updated, or removed.""" evaluate_product_rules_task.delay(product_uuid=instance.product.uuid) From 3328c738c1f57fe13cbb1dd35e51c1f27fb4ffe2 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 18 Aug 2026 13:53:02 +0200 Subject: [PATCH 84/90] deactivate signal in the context of imports Signed-off-by: tdruez --- product_portfolio/importers.py | 51 +++++++++++++++++++++++++++++-- vulnerabilities/triage/signals.py | 2 +- vulnerabilities/triage/tasks.py | 22 +++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/product_portfolio/importers.py b/product_portfolio/importers.py index a790a42c..edab66a9 100644 --- a/product_portfolio/importers.py +++ b/product_portfolio/importers.py @@ -20,6 +20,8 @@ from django.db import transaction from django.db.models import ObjectDoesNotExist from django.db.models import Q +from django.db.models.signals import post_delete +from django.db.models.signals import post_save from django.utils.translation import gettext_lazy as _ from license_expression import Licensing @@ -40,6 +42,8 @@ from dje.models import Dataspace from dje.utils import get_help_text from dje.utils import is_uuid4 +from policy.signals import evaluate_product_rules_on_productpackage_change +from policy.tasks import evaluate_product_rules_task from product_portfolio.forms import ProductComponentLicenseExpressionFormMixin from product_portfolio.models import CodebaseResource from product_portfolio.models import CodebaseResourceUsage @@ -50,6 +54,8 @@ from product_portfolio.models import ProductPackage from product_portfolio.models import ProductRelationStatus from product_portfolio.models import ScanCodeProject +from vulnerabilities.triage.signals import reevaluate_on_product_package_change +from vulnerabilities.triage.tasks import reevaluate_product_triage_rulesets_task logger = logging.getLogger(__name__) @@ -62,6 +68,35 @@ def log_elapsed(label): logger.info(f"{label}: {time.perf_counter() - start:.1f}s") +@contextmanager +def paused_product_package_reevaluation(): + """ + Pause the policy and triage re-evaluation signals triggered by ProductPackage changes, + for the duration of a bulk import. Call `reevaluate_products()` once the import completes + to evaluate each affected product exactly once, instead of once per imported row. + """ + receivers = [ + evaluate_product_rules_on_productpackage_change, + reevaluate_on_product_package_change, + ] + for receiver in receivers: + post_save.disconnect(receiver, sender=ProductPackage) + post_delete.disconnect(receiver, sender=ProductPackage) + try: + yield + finally: + for receiver in receivers: + post_save.connect(receiver, sender=ProductPackage) + post_delete.connect(receiver, sender=ProductPackage) + + +def reevaluate_products(products): + """Queue the policy and triage re-evaluation once for each of the given products.""" + for product in products: + evaluate_product_rules_task.delay(product_uuid=product.uuid) + reevaluate_product_triage_rulesets_task.delay(product_uuid=product.uuid) + + class CleanProductMixin(ComponentRelatedFieldImportMixin): def clean_product(self): queryset = Product.objects.get_queryset(self.user) @@ -230,6 +265,14 @@ def clean_package(self): class ProductPackageImporter(BaseImporter): model_form = ProductPackageImportForm + def save_all(self): + with paused_product_package_reevaluation(): + super().save_all() + + touched_product_packages = self.results["added"] + self.results["modified"] + products = {product_package.product for product_package in touched_product_packages} + reevaluate_products(products) + class CodebaseResourceImportForm(CleanProductMixin, BaseImportModelForm): product = forms.CharField( @@ -434,7 +477,9 @@ def save(self): self.create_scancode_project() self.load_data_from_file() self.validate_headers() - self.import_packages() + with paused_product_package_reevaluation(): + self.import_packages() + reevaluate_products([self.product]) if self.create_codebase_resources: self.import_codebase_resources() self.update_scancode_project() @@ -720,7 +765,9 @@ def save(self): self.dependencies = scancodeio.fetch_project_dependencies(self.project_uuid) with log_elapsed("import_packages"): - self.import_packages() + with paused_product_package_reevaluation(): + self.import_packages() + reevaluate_products([self.product]) if self.create_dependencies: with log_elapsed("import_dependencies"): diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index 91818e84..318012dd 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -49,7 +49,7 @@ def reevaluate_on_analysis_change(sender, instance, **kwargs): """Re-evaluate triage on a product when an vulnerability analysis is updated.""" signal = kwargs.get("signal") if signal == post_save and instance.applied_by_preset_id: - # When the analysis is created by the triage engine itself, the evaluation is skipped. + # When the analysis is created by the triage engine itself, the evaluation is skipped. return # When a user explicitly deletes their analysis, skip preset application to avoid diff --git a/vulnerabilities/triage/tasks.py b/vulnerabilities/triage/tasks.py index aa387235..320d4507 100644 --- a/vulnerabilities/triage/tasks.py +++ b/vulnerabilities/triage/tasks.py @@ -8,14 +8,36 @@ import logging +from django.apps import apps + from django_rq import job +from dje.models import get_unsecured_manager from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.engine import reevaluate_product_rulesets from vulnerabilities.triage.models import ProductTriageRuleset logger = logging.getLogger(__name__) +@job +def reevaluate_product_triage_rulesets_task(product_uuid): + """Re-evaluate all enabled triage rulesets assigned to the given product.""" + Product = apps.get_model("product_portfolio", "product") + + try: + product = get_unsecured_manager(Product).get(uuid=product_uuid) + except Product.DoesNotExist: + logger.error( + f"reevaluate_product_triage_rulesets_task: product {product_uuid} not found," + " skipping." + ) + return + + logger.info(f"Evaluating triage rulesets for product {product}") + reevaluate_product_rulesets(product) + + @job def evaluate_all_products_vulnerability_triage_task(): """Evaluate all enabled triage rulesets against their assigned products.""" From ee6bdb4db1de45da7abfefe913083634dc81193f Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 18 Aug 2026 14:01:31 +0200 Subject: [PATCH 85/90] move signal in ad,in and view Signed-off-by: tdruez --- product_portfolio/admin.py | 2 ++ product_portfolio/views.py | 18 +++++++++--------- vulnerabilities/triage/signals.py | 10 ---------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/product_portfolio/admin.py b/product_portfolio/admin.py index ccb1789d..c3504038 100644 --- a/product_portfolio/admin.py +++ b/product_portfolio/admin.py @@ -72,6 +72,7 @@ from product_portfolio.models import ProductRelationStatus from product_portfolio.models import ProductStatus from reporting.filters import ReportingQueryListFilter +from vulnerabilities.triage.engine import reevaluate_product_rulesets from vulnerabilities.triage.models import ProductTriageRuleset @@ -247,6 +248,7 @@ def save_model(self, request, obj, form, change): relationship.uuid = uuid.uuid4() relationship.product = obj relationship.save() + reevaluate_product_rulesets(obj) def get_obj_perms_user_select_form(self, request): """ diff --git a/product_portfolio/views.py b/product_portfolio/views.py index f1d5563a..c1fec367 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -150,6 +150,7 @@ from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.models import get_risk_level +from vulnerabilities.triage.engine import reevaluate_product_rulesets from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import ProductTriageRuleset from vulnerabilities.triage.models import TriageAction @@ -2249,20 +2250,19 @@ def manage_triage_rulesets_view(request, dataspace, name, version=""): product=product, ruleset__enabled=True ).select_related("ruleset") } - for ruleset in available_rulesets: - ruleset_uuid = str(ruleset.uuid) - if ruleset_uuid in submitted_uuids and ruleset_uuid not in current_assignments: - # The evaluate_on_assign signal evaluates the ruleset against the product; - # wrapping in atomic() rolls the assignment back if that evaluation fails. - with transaction.atomic(): + with transaction.atomic(): + for ruleset in available_rulesets: + ruleset_uuid = str(ruleset.uuid) + if ruleset_uuid in submitted_uuids and ruleset_uuid not in current_assignments: ProductTriageRuleset.objects.create( product=product, ruleset=ruleset, dataspace=product.dataspace, ) - for ruleset_uuid, assignment in current_assignments.items(): - if ruleset_uuid not in submitted_uuids: - assignment.delete() + for ruleset_uuid, assignment in current_assignments.items(): + if ruleset_uuid not in submitted_uuids: + assignment.delete() + reevaluate_product_rulesets(product) return JsonResponse({"success": True}) assigned_ruleset_ids = set(product.product_triage_rulesets.values_list("ruleset_id", flat=True)) diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index 318012dd..0ad802d9 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -11,20 +11,10 @@ from django.dispatch import receiver from vulnerabilities.triage.engine import delete_preset_analyses_for_product -from vulnerabilities.triage.engine import evaluate_ruleset from vulnerabilities.triage.engine import reevaluate_product_rulesets from vulnerabilities.triage.models import TriageRecord -@receiver(post_save, sender="vulnerabilities_triage.ProductTriageRuleset") -def evaluate_on_assign(sender, instance, created, **kwargs): - """Evaluate the ruleset against the product as soon as it is assigned.""" - if not created or not instance.ruleset.enabled: - return - - evaluate_ruleset(ruleset=instance.ruleset, product=instance.product) - - @receiver(post_delete, sender="vulnerabilities_triage.ProductTriageRuleset") def delete_triage_records_on_unassign(sender, instance, **kwargs): """Delete triage records and associated preset analyses when a ruleset is de-assigned.""" From 57a7b4dccf6208cde2c5859744e6caa1a27f5de3 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 18 Aug 2026 14:14:48 +0200 Subject: [PATCH 86/90] refactor the signal related to preset analysis Signed-off-by: tdruez --- product_portfolio/views.py | 4 ++ vulnerabilities/triage/admin.py | 10 +++++ vulnerabilities/triage/engine.py | 45 +++++++++++++++++++ .../commands/create_triage_rulesets.py | 3 ++ vulnerabilities/triage/signals.py | 21 --------- 5 files changed, 62 insertions(+), 21 deletions(-) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index c1fec367..f69e7720 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -150,6 +150,7 @@ from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.models import get_risk_level +from vulnerabilities.triage.engine import delete_triage_records_for_assignment from vulnerabilities.triage.engine import reevaluate_product_rulesets from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import ProductTriageRuleset @@ -2262,6 +2263,9 @@ def manage_triage_rulesets_view(request, dataspace, name, version=""): for ruleset_uuid, assignment in current_assignments.items(): if ruleset_uuid not in submitted_uuids: assignment.delete() + delete_triage_records_for_assignment( + ruleset=assignment.ruleset, product=product + ) reevaluate_product_rulesets(product) return JsonResponse({"success": True}) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 16ab2f75..08754723 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -13,6 +13,7 @@ from dje.admin import DataspacedAdmin from dje.admin import dejacode_site from dje.list_display import AsLink +from vulnerabilities.triage.engine import delete_preset_analyses_before_ruleset_delete from vulnerabilities.triage.engine import evaluate_ruleset from vulnerabilities.triage.forms import AnalysisPresetForm from vulnerabilities.triage.forms import TriageRulesetForm @@ -146,6 +147,15 @@ def save_model(self, request, obj, form, change): for assignment in obj.product_triage_rulesets.select_related("product"): evaluate_ruleset(ruleset=obj, product=assignment.product) + def delete_model(self, request, obj): + delete_preset_analyses_before_ruleset_delete(obj) + super().delete_model(request, obj) + + def delete_queryset(self, request, queryset): + for obj in queryset: + delete_preset_analyses_before_ruleset_delete(obj) + super().delete_queryset(request, queryset) + def get_form(self, request, obj=None, change=False, **kwargs): kwargs["fields"] = [ "name", diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index 34c11fa4..d9082a9b 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -130,6 +130,24 @@ def delete_preset_analyses_for_product(preset_id, product, vulnerability_ids): ).delete() +def delete_triage_records_for_assignment(ruleset, product): + """ + Delete triage records and associated preset analyses for a ruleset being + unassigned from a product. + """ + matching_records = TriageRecord.objects.filter(ruleset=ruleset, product=product) + stale_vulnerability_ids = list(matching_records.values_list("vulnerability_id", flat=True)) + # Records with an open Request are kept so reassigning the ruleset reconnects to it + # instead of opening a duplicate Request. + matching_records.filter(request__isnull=True).delete() + if ruleset.analysis_preset_id and stale_vulnerability_ids: + delete_preset_analyses_for_product( + preset_id=ruleset.analysis_preset_id, + product=product, + vulnerability_ids=stale_vulnerability_ids, + ) + + def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id, apply_preset=True): """ Create or update one TriageRecord per matching vulnerability, then @@ -228,6 +246,33 @@ def evaluate_ruleset(ruleset, product, apply_preset=True): ) +def delete_preset_analyses_before_ruleset_delete(ruleset): + """ + Delete VulnerabilityAnalysis records applied by the ruleset's preset, for every + product currently assigned to it. + + Must run before the ruleset itself is deleted: its TriageRecords have no signal of + their own, so Django cascade-deletes them via a fast bulk query before a post_delete + signal on the ruleset or its assignments would even get a chance to see them. + """ + if not ruleset.analysis_preset_id: + return + + assignments = ProductTriageRuleset.objects.filter(ruleset=ruleset).select_related("product") + for assignment in assignments: + vulnerability_ids = list( + TriageRecord.objects.filter( + ruleset=ruleset, product=assignment.product + ).values_list("vulnerability_id", flat=True) + ) + if vulnerability_ids: + delete_preset_analyses_for_product( + preset_id=ruleset.analysis_preset_id, + product=assignment.product, + vulnerability_ids=vulnerability_ids, + ) + + def reevaluate_product_rulesets(product, apply_preset=True): """Re-evaluate all enabled triage rulesets currently assigned to the product.""" assignments = ProductTriageRuleset.objects.filter( diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index bd3e2e2f..9198b2df 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -10,6 +10,7 @@ from django.core.management.base import CommandError from dje.models import Dataspace +from vulnerabilities.triage.engine import delete_preset_analyses_before_ruleset_delete from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRuleset @@ -179,6 +180,8 @@ def handle(self, *args, **options): self.stdout.write("Reset cancelled.") return + for ruleset in TriageRuleset.objects.filter(dataspace=dataspace): + delete_preset_analyses_before_ruleset_delete(ruleset) deleted_rulesets, _ = TriageRuleset.objects.filter(dataspace=dataspace).delete() deleted_presets, _ = AnalysisPreset.objects.filter(dataspace=dataspace).delete() self.stdout.write( diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index 0ad802d9..9947c352 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -10,28 +10,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from vulnerabilities.triage.engine import delete_preset_analyses_for_product from vulnerabilities.triage.engine import reevaluate_product_rulesets -from vulnerabilities.triage.models import TriageRecord - - -@receiver(post_delete, sender="vulnerabilities_triage.ProductTriageRuleset") -def delete_triage_records_on_unassign(sender, instance, **kwargs): - """Delete triage records and associated preset analyses when a ruleset is de-assigned.""" - matching_records = TriageRecord.objects.filter( - ruleset=instance.ruleset, - product=instance.product, - ) - stale_vulnerability_ids = list(matching_records.values_list("vulnerability_id", flat=True)) - # Records with an open Request are kept so reassigning the ruleset reconnects to it - # instead of opening a duplicate Request. - matching_records.filter(request__isnull=True).delete() - if instance.ruleset.analysis_preset_id and stale_vulnerability_ids: - delete_preset_analyses_for_product( - preset_id=instance.ruleset.analysis_preset_id, - product=instance.product, - vulnerability_ids=stale_vulnerability_ids, - ) @receiver([post_save, post_delete], sender="vulnerabilities.VulnerabilityAnalysis") From 327ecb714dff9fcaab9d41396cf982c5fd2dd0f2 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 18 Aug 2026 14:25:30 +0200 Subject: [PATCH 87/90] fix reevaluate_on_analysis_change Signed-off-by: tdruez --- vulnerabilities/triage/signals.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py index 9947c352..5d5be17c 100644 --- a/vulnerabilities/triage/signals.py +++ b/vulnerabilities/triage/signals.py @@ -16,18 +16,15 @@ @receiver([post_save, post_delete], sender="vulnerabilities.VulnerabilityAnalysis") def reevaluate_on_analysis_change(sender, instance, **kwargs): """Re-evaluate triage on a product when an vulnerability analysis is updated.""" - signal = kwargs.get("signal") - if signal == post_save and instance.applied_by_preset_id: - # When the analysis is created by the triage engine itself, the evaluation is skipped. + if instance.applied_by_preset_id: + # A write or delete made by the triage engine itself always happens inside an + # evaluation pass that already covers every ruleset assigned to the product. return - # When a user explicitly deletes their analysis, skip preset application to avoid - # having the engine immediately recreate it. - is_user_delete = signal == post_delete and not instance.applied_by_preset_id - reevaluate_product_rulesets( - instance.product_package.product, - apply_preset=not is_user_delete, - ) + # Skip preset re-application on a user's own delete, to avoid the engine + # immediately recreating the analysis they just removed. + apply_preset = kwargs.get("signal") != post_delete + reevaluate_product_rulesets(instance.product_package.product, apply_preset=apply_preset) @receiver([post_save, post_delete], sender="product_portfolio.ProductPackage") From bd52c143ba3515b675ad5f28d68f639160c08350 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 18 Aug 2026 14:32:06 +0200 Subject: [PATCH 88/90] simplify the sync Signed-off-by: tdruez --- vulnerabilities/triage/admin.py | 10 ----- vulnerabilities/triage/engine.py | 42 +++---------------- .../commands/create_triage_rulesets.py | 3 -- 3 files changed, 6 insertions(+), 49 deletions(-) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 08754723..16ab2f75 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -13,7 +13,6 @@ from dje.admin import DataspacedAdmin from dje.admin import dejacode_site from dje.list_display import AsLink -from vulnerabilities.triage.engine import delete_preset_analyses_before_ruleset_delete from vulnerabilities.triage.engine import evaluate_ruleset from vulnerabilities.triage.forms import AnalysisPresetForm from vulnerabilities.triage.forms import TriageRulesetForm @@ -147,15 +146,6 @@ def save_model(self, request, obj, form, change): for assignment in obj.product_triage_rulesets.select_related("product"): evaluate_ruleset(ruleset=obj, product=assignment.product) - def delete_model(self, request, obj): - delete_preset_analyses_before_ruleset_delete(obj) - super().delete_model(request, obj) - - def delete_queryset(self, request, queryset): - for obj in queryset: - delete_preset_analyses_before_ruleset_delete(obj) - super().delete_queryset(request, queryset) - def get_form(self, request, obj=None, change=False, **kwargs): kwargs["fields"] = [ "name", diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index d9082a9b..61fb0eca 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -132,20 +132,17 @@ def delete_preset_analyses_for_product(preset_id, product, vulnerability_ids): def delete_triage_records_for_assignment(ruleset, product): """ - Delete triage records and associated preset analyses for a ruleset being - unassigned from a product. + Delete triage records for a ruleset being unassigned from a product. + + VulnerabilityAnalysis records already applied by the ruleset's preset are left + untouched: the facts that justified them have not changed, only the decision to keep + monitoring with this ruleset. This matches disabling a ruleset, which also leaves + previously-applied analyses in place. """ matching_records = TriageRecord.objects.filter(ruleset=ruleset, product=product) - stale_vulnerability_ids = list(matching_records.values_list("vulnerability_id", flat=True)) # Records with an open Request are kept so reassigning the ruleset reconnects to it # instead of opening a duplicate Request. matching_records.filter(request__isnull=True).delete() - if ruleset.analysis_preset_id and stale_vulnerability_ids: - delete_preset_analyses_for_product( - preset_id=ruleset.analysis_preset_id, - product=product, - vulnerability_ids=stale_vulnerability_ids, - ) def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id, apply_preset=True): @@ -246,33 +243,6 @@ def evaluate_ruleset(ruleset, product, apply_preset=True): ) -def delete_preset_analyses_before_ruleset_delete(ruleset): - """ - Delete VulnerabilityAnalysis records applied by the ruleset's preset, for every - product currently assigned to it. - - Must run before the ruleset itself is deleted: its TriageRecords have no signal of - their own, so Django cascade-deletes them via a fast bulk query before a post_delete - signal on the ruleset or its assignments would even get a chance to see them. - """ - if not ruleset.analysis_preset_id: - return - - assignments = ProductTriageRuleset.objects.filter(ruleset=ruleset).select_related("product") - for assignment in assignments: - vulnerability_ids = list( - TriageRecord.objects.filter( - ruleset=ruleset, product=assignment.product - ).values_list("vulnerability_id", flat=True) - ) - if vulnerability_ids: - delete_preset_analyses_for_product( - preset_id=ruleset.analysis_preset_id, - product=assignment.product, - vulnerability_ids=vulnerability_ids, - ) - - def reevaluate_product_rulesets(product, apply_preset=True): """Re-evaluate all enabled triage rulesets currently assigned to the product.""" assignments = ProductTriageRuleset.objects.filter( diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index 9198b2df..bd3e2e2f 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -10,7 +10,6 @@ from django.core.management.base import CommandError from dje.models import Dataspace -from vulnerabilities.triage.engine import delete_preset_analyses_before_ruleset_delete from vulnerabilities.triage.models import AnalysisPreset from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRuleset @@ -180,8 +179,6 @@ def handle(self, *args, **options): self.stdout.write("Reset cancelled.") return - for ruleset in TriageRuleset.objects.filter(dataspace=dataspace): - delete_preset_analyses_before_ruleset_delete(ruleset) deleted_rulesets, _ = TriageRuleset.objects.filter(dataspace=dataspace).delete() deleted_presets, _ = AnalysisPreset.objects.filter(dataspace=dataspace).delete() self.stdout.write( From a27f71f1aa2edbc74689489092132758951357fe Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 18 Aug 2026 14:53:02 +0200 Subject: [PATCH 89/90] optimizations Signed-off-by: tdruez --- policy/tasks.py | 12 +++++---- product_portfolio/admin.py | 18 +++++++------ vulnerabilities/triage/engine.py | 25 +++++++++++++++++++ .../management/commands/evaluate_triage.py | 7 +++--- vulnerabilities/triage/tasks.py | 18 +++---------- 5 files changed, 49 insertions(+), 31 deletions(-) diff --git a/policy/tasks.py b/policy/tasks.py index 77f8114d..874fca77 100644 --- a/policy/tasks.py +++ b/policy/tasks.py @@ -33,10 +33,10 @@ def evaluate_product_rules_task(product_uuid): logger.error(f"evaluate_product_rules_task: product {product_uuid} not found, skipping.") return - logger.info(f"Evaluating policy rules for product {product}") + logger.info(f"Evaluating policy rules for product id={product.id}") new_violations, resolved_count = evaluate_rules(product) logger.info( - f"Policy rules evaluated for {product}: " + f"Policy rules evaluated for product id={product.id}: " f"{len(new_violations)} new violation(s), {resolved_count} resolved." ) @@ -59,14 +59,16 @@ def evaluate_all_products_rules_task(include_locked=False, product_uuids=None): logger.info(f"Starting policy rule evaluation for {count} product(s).") for product in products: - logger.info(f"Evaluating policy rules for product {product}") + logger.info(f"Evaluating policy rules for product id={product.id}") try: new_violations, resolved_count = evaluate_rules(product) except Exception: - logger.exception(f"Policy rule evaluation failed for product {product}, skipping.") + logger.exception( + f"Policy rule evaluation failed for product id={product.id}, skipping." + ) continue logger.info( - f"Policy rules evaluated for {product}: " + f"Policy rules evaluated for product id={product.id}: " f"{len(new_violations)} new violation(s), {resolved_count} resolved." ) diff --git a/product_portfolio/admin.py b/product_portfolio/admin.py index c3504038..498620ca 100644 --- a/product_portfolio/admin.py +++ b/product_portfolio/admin.py @@ -59,6 +59,8 @@ from product_portfolio.importers import CodebaseResourceImporter from product_portfolio.importers import ProductComponentImporter from product_portfolio.importers import ProductPackageImporter +from product_portfolio.importers import paused_product_package_reevaluation +from product_portfolio.importers import reevaluate_products from product_portfolio.inlines import CodebaseResourceUsageDeployedFromInline from product_portfolio.inlines import CodebaseResourceUsageDeployedToInline from product_portfolio.inlines import ProductComponentInline @@ -72,7 +74,6 @@ from product_portfolio.models import ProductRelationStatus from product_portfolio.models import ProductStatus from reporting.filters import ReportingQueryListFilter -from vulnerabilities.triage.engine import reevaluate_product_rulesets from vulnerabilities.triage.models import ProductTriageRuleset @@ -242,13 +243,14 @@ def save_model(self, request, obj, form, change): if "_saveasnew" in request.POST: old_product_id = request.resolver_match.kwargs.get("object_id") old_product = self.get_object(request, old_product_id) - for model_class in [ProductComponent, ProductPackage, ProductTriageRuleset]: - for relationship in model_class.objects.filter(product=old_product): - relationship.id = None - relationship.uuid = uuid.uuid4() - relationship.product = obj - relationship.save() - reevaluate_product_rulesets(obj) + with paused_product_package_reevaluation(): + for model_class in [ProductComponent, ProductPackage, ProductTriageRuleset]: + for relationship in model_class.objects.filter(product=old_product): + relationship.id = None + relationship.uuid = uuid.uuid4() + relationship.product = obj + relationship.save() + reevaluate_products([obj]) def get_obj_perms_user_select_form(self, request): """ diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index 61fb0eca..4e7344a9 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -6,6 +6,8 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +import logging + from django.apps import apps from django.utils import timezone @@ -14,6 +16,8 @@ from vulnerabilities.triage.rules import RULE_REGISTRY from vulnerabilities.triage.rules import rule_parameters_from_config +logger = logging.getLogger(__name__) + def collect_matches(ruleset, product): """ @@ -243,6 +247,27 @@ def evaluate_ruleset(ruleset, product, apply_preset=True): ) +def evaluate_assignments(assignments): + """ + Evaluate each ProductTriageRuleset assignment in the given queryset, skipping and + logging any that raise instead of aborting the whole batch. + + Returns the number of assignments successfully evaluated. + """ + evaluated_count = 0 + for assignment in assignments: + try: + evaluate_ruleset(ruleset=assignment.ruleset, product=assignment.product) + except Exception: + logger.exception( + f"Triage evaluation failed for ruleset id={assignment.ruleset_id} /" + f" product id={assignment.product_id}, skipping." + ) + continue + evaluated_count += 1 + return evaluated_count + + def reevaluate_product_rulesets(product, apply_preset=True): """Re-evaluate all enabled triage rulesets currently assigned to the product.""" assignments = ProductTriageRuleset.objects.filter( diff --git a/vulnerabilities/triage/management/commands/evaluate_triage.py b/vulnerabilities/triage/management/commands/evaluate_triage.py index 34f078ea..f55d4f29 100644 --- a/vulnerabilities/triage/management/commands/evaluate_triage.py +++ b/vulnerabilities/triage/management/commands/evaluate_triage.py @@ -10,7 +10,7 @@ from django.core.management.base import CommandError from dje.models import Dataspace -from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.engine import evaluate_assignments from vulnerabilities.triage.models import ProductTriageRuleset from vulnerabilities.triage.models import TriageRecord @@ -45,9 +45,8 @@ def handle(self, *args, **options): self.stdout.write("No active ruleset assignments found.") return - for assignment in assignments: - self.stdout.write(f" {assignment.product} / {assignment.ruleset}") - evaluate_ruleset(ruleset=assignment.ruleset, product=assignment.product) + evaluated_count = evaluate_assignments(assignments) + self.stdout.write(f"Evaluated: {evaluated_count}/{assignment_count}") total = TriageRecord.objects.filter(dataspace=dataspace).count() self.stdout.write( diff --git a/vulnerabilities/triage/tasks.py b/vulnerabilities/triage/tasks.py index 320d4507..760ef016 100644 --- a/vulnerabilities/triage/tasks.py +++ b/vulnerabilities/triage/tasks.py @@ -13,7 +13,7 @@ from django_rq import job from dje.models import get_unsecured_manager -from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.engine import evaluate_assignments from vulnerabilities.triage.engine import reevaluate_product_rulesets from vulnerabilities.triage.models import ProductTriageRuleset @@ -34,7 +34,7 @@ def reevaluate_product_triage_rulesets_task(product_uuid): ) return - logger.info(f"Evaluating triage rulesets for product {product}") + logger.info(f"Evaluating triage rulesets for product id={product.id}") reevaluate_product_rulesets(product) @@ -50,15 +50,5 @@ def evaluate_all_products_vulnerability_triage_task(): count = assignments.count() logger.info(f"Starting triage evaluation for {count} ruleset assignment(s).") - for assignment in assignments: - logger.info(f"Evaluating triage ruleset {assignment.ruleset} for {assignment.product}") - try: - evaluate_ruleset(ruleset=assignment.ruleset, product=assignment.product) - except Exception: - logger.exception( - f"Triage evaluation failed for {assignment.ruleset} / {assignment.product}," - " skipping." - ) - continue - - logger.info("Triage evaluation complete.") + evaluated_count = evaluate_assignments(assignments) + logger.info(f"Triage evaluation complete: {evaluated_count}/{count} evaluated.") From cad0c61da0f97937eeca03fc95da7a183f58438a Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 18 Aug 2026 17:56:40 +0200 Subject: [PATCH 90/90] fix and add unit tests Signed-off-by: tdruez --- product_portfolio/tests/test_admin.py | 33 ++ product_portfolio/tests/test_importers.py | 22 ++ product_portfolio/tests/test_views.py | 15 +- vulnerabilities/triage/engine.py | 2 +- vulnerabilities/triage/tasks.py | 3 +- vulnerabilities/triage/tests/test_admin.py | 181 ++++++++++ vulnerabilities/triage/tests/test_commands.py | 133 ++++++++ vulnerabilities/triage/tests/test_engine.py | 176 ++++++++++ vulnerabilities/triage/tests/test_forms.py | 2 +- vulnerabilities/triage/tests/test_signals.py | 322 +----------------- vulnerabilities/triage/tests/test_tasks.py | 99 ++++-- 11 files changed, 623 insertions(+), 365 deletions(-) create mode 100644 vulnerabilities/triage/tests/test_admin.py create mode 100644 vulnerabilities/triage/tests/test_commands.py diff --git a/product_portfolio/tests/test_admin.py b/product_portfolio/tests/test_admin.py index 1e015173..e5c27dce 100644 --- a/product_portfolio/tests/test_admin.py +++ b/product_portfolio/tests/test_admin.py @@ -457,6 +457,39 @@ def test_product_admin_changeform_save_as_includes_relationships(self): self.assertEqual(1, new_product.productpackages.count()) self.assertEqual(1, new_product.product_triage_rulesets.count()) + @patch("product_portfolio.admin.reevaluate_products") + def test_product_admin_changeform_save_as_evaluates_the_new_product_once(self, mock_reevaluate): + # Cloning must evaluate the new product exactly once, not once per cloned + # ProductPackage relationship. + package2 = make_package(self.dataspace) + ProductPackage.objects.create( + product=self.product1, package=self.package1, dataspace=self.dataspace + ) + ProductPackage.objects.create( + product=self.product1, package=package2, dataspace=self.dataspace + ) + self.client.login(username=self.user.username, password="secret") + + url = self.product1.get_admin_url() + data = { + "_saveasnew": "Save as new", + "name": self.product1.name, + "version": "new version 2", + "productcomponents-INITIAL_FORMS": 0, + "productcomponents-TOTAL_FORMS": 0, + "productpackages-INITIAL_FORMS": 0, + "productpackages-TOTAL_FORMS": 0, + } + + response = self.client.post(url, data) + + self.assertEqual(response.status_code, 302) + new_product = Product.unsecured_objects.get( + name=self.product1.name, version="new version 2" + ) + self.assertEqual(2, new_product.productpackages.count()) + mock_reevaluate.assert_called_once_with([new_product]) + def test_codebaseresource_admin_changeform_product_prefill_on_save_addanother(self): self.client.login(username=self.user.username, password="secret") url = reverse("admin:product_portfolio_codebaseresource_add") diff --git a/product_portfolio/tests/test_importers.py b/product_portfolio/tests/test_importers.py index c4983b69..39688ad3 100644 --- a/product_portfolio/tests/test_importers.py +++ b/product_portfolio/tests/test_importers.py @@ -378,6 +378,28 @@ def test_productpackage_import_save_all(self): self.assertEqual(False, productpackage.is_deployed) self.assertEqual(True, productpackage.is_modified) + @mock.patch("product_portfolio.importers.reevaluate_product_triage_rulesets_task") + @mock.patch("product_portfolio.importers.evaluate_product_rules_task") + def test_productpackage_import_evaluates_each_product_once_for_multiple_rows( + self, mock_policy_task, mock_triage_task + ): + formset_data = self.package_formset_data.copy() + package1 = Package.objects.create(filename="p1.zip", dataspace=self.dataspace) + package2 = Package.objects.create(filename="p2.zip", dataspace=self.dataspace) + + formset_data["form-TOTAL_FORMS"] = "2" + formset_data["form-0-package"] = package1.filename + formset_data["form-1-product"] = formset_data["form-0-product"] + formset_data["form-1-package"] = package2.filename + + importer = ProductPackageImporter(self.super_user, formset_data=formset_data) + self.assertTrue(importer.formset.is_valid()) + importer.save_all() + + self.assertEqual(2, len(importer.results["added"])) + mock_policy_task.delay.assert_called_once_with(product_uuid=self.p1.uuid) + mock_triage_task.delay.assert_called_once_with(product_uuid=self.p1.uuid) + class CodebaseResourceImporterTestCase(TestCase): def setUp(self): diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 3e528e8c..18ed8b56 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -4416,8 +4416,8 @@ def test_post_without_change_perm_returns_404(self): response = self.client.post(url) self.assertEqual(404, response.status_code) - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_post_assigns_the_submitted_rulesets(self, mock_evaluate): + @patch("product_portfolio.views.reevaluate_product_rulesets") + def test_post_assigns_the_submitted_rulesets(self, mock_reevaluate): self.client.login(username="nexb_user", password="secret") url = self.product1.get_manage_triage_rulesets_url() response = self.client.post(url, {"ruleset_uuids": [str(self.ruleset.uuid)]}) @@ -4427,14 +4427,14 @@ def test_post_assigns_the_submitted_rulesets(self, mock_evaluate): product=self.product1, ruleset=self.ruleset ).exists() ) - mock_evaluate.assert_called_once_with(ruleset=self.ruleset, product=self.product1) + mock_reevaluate.assert_called_once_with(self.product1) - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_post_unassigns_the_deselected_rulesets(self, mock_evaluate): + @patch("product_portfolio.views.delete_triage_records_for_assignment") + @patch("product_portfolio.views.reevaluate_product_rulesets") + def test_post_unassigns_the_deselected_rulesets(self, mock_reevaluate, mock_delete): ProductTriageRuleset.objects.create( product=self.product1, ruleset=self.ruleset, dataspace=self.dataspace ) - mock_evaluate.reset_mock() self.client.login(username="nexb_user", password="secret") url = self.product1.get_manage_triage_rulesets_url() response = self.client.post(url, {"ruleset_uuids": []}) @@ -4444,7 +4444,8 @@ def test_post_unassigns_the_deselected_rulesets(self, mock_evaluate): product=self.product1, ruleset=self.ruleset ).exists() ) - mock_evaluate.assert_not_called() + mock_delete.assert_called_once_with(ruleset=self.ruleset, product=self.product1) + mock_reevaluate.assert_called_once_with(self.product1) class TabCompliancePolicyContextTestCase(TestCase): diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index 4e7344a9..3cfe3269 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -96,7 +96,7 @@ def apply_preset_for_vulnerabilities(preset, product, vulnerability_ids): existing = existing_analyses.get((product_package_id, vulnerability_id)) if existing is not None and existing.applied_by_preset_id is None: - continue # Human-owned analysis -- never overwrite + continue # User-owned analysis, never overwrite if existing is None: product_package = product_packages_by_id[product_package_id] diff --git a/vulnerabilities/triage/tasks.py b/vulnerabilities/triage/tasks.py index 760ef016..f5527d58 100644 --- a/vulnerabilities/triage/tasks.py +++ b/vulnerabilities/triage/tasks.py @@ -29,8 +29,7 @@ def reevaluate_product_triage_rulesets_task(product_uuid): product = get_unsecured_manager(Product).get(uuid=product_uuid) except Product.DoesNotExist: logger.error( - f"reevaluate_product_triage_rulesets_task: product {product_uuid} not found," - " skipping." + f"reevaluate_product_triage_rulesets_task: product {product_uuid} not found, skipping." ) return diff --git a/vulnerabilities/triage/tests/test_admin.py b/vulnerabilities/triage/tests/test_admin.py new file mode 100644 index 00000000..4f6a0901 --- /dev/null +++ b/vulnerabilities/triage/tests/test_admin.py @@ -0,0 +1,181 @@ +# +# 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.contrib.contenttypes.models import ContentType +from django.test import TestCase +from django.urls import reverse + +from component_catalog.tests import make_package +from dje.models import Dataspace +from dje.tests import create_superuser +from dje.tests import create_user +from product_portfolio.models import Product +from product_portfolio.tests import make_product +from product_portfolio.tests import make_product_package +from vulnerabilities.tests import make_vulnerability +from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.models import TriageAction +from vulnerabilities.triage.models import TriageRecord +from vulnerabilities.triage.tests import make_product_triage_ruleset +from vulnerabilities.triage.tests import make_triage_ruleset +from workflow.models import RequestTemplate + +RULE_CONFIG_DATA = { + "rule_risk_score_enabled": "on", + "rule_risk_score_min_risk_score": "8.0", +} + + +class TriageRulesetAdminSaveModelTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.super_user = create_superuser("nexb_user", self.dataspace) + self.client.login(username="nexb_user", password="secret") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + make_product_package(self.product, package=self.package) + + def test_creating_a_ruleset_does_not_evaluate_any_product(self): + make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + url = reverse("admin:vulnerabilities_triage_triageruleset_add") + data = { + "name": "My Ruleset", + "precedence": 100, + "enabled": "on", + **RULE_CONFIG_DATA, + } + + response = self.client.post(url, data) + + self.assertEqual(302, response.status_code) + self.assertFalse(TriageRecord.objects.exists()) + + def test_updating_an_enabled_assigned_ruleset_reevaluates_its_products(self): + vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, + recommended_action=TriageAction.UPGRADE, + enabled=True, + ) + make_product_triage_ruleset(self.product, ruleset=ruleset) + self.assertFalse(TriageRecord.objects.exists()) + + url = ruleset.get_admin_url() + data = { + "name": ruleset.name, + "precedence": ruleset.precedence, + "recommended_action": TriageAction.UPGRADE, + "enabled": "on", + **RULE_CONFIG_DATA, + } + + response = self.client.post(url, data) + + self.assertEqual(302, response.status_code) + record = TriageRecord.objects.get() + self.assertEqual(vulnerability, record.vulnerability) + + def test_disabling_a_ruleset_deletes_its_triage_records(self): + make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, + recommended_action=TriageAction.UPGRADE, + enabled=True, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + make_product_triage_ruleset(self.product, ruleset=ruleset) + evaluate_ruleset(ruleset, self.product) + self.assertTrue(TriageRecord.objects.exists()) + + url = ruleset.get_admin_url() + data = { + "name": ruleset.name, + "precedence": ruleset.precedence, + "recommended_action": TriageAction.UPGRADE, + # "enabled" omitted: disables the ruleset. + **RULE_CONFIG_DATA, + } + + response = self.client.post(url, data) + + self.assertEqual(302, response.status_code) + self.assertFalse(TriageRecord.objects.exists()) + + def test_disabling_a_ruleset_keeps_records_that_have_an_open_request(self): + vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + requester = create_user("requester", self.dataspace) + request_template = RequestTemplate.objects.create( + name="Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=requester, + ) + ruleset = make_triage_ruleset( + self.dataspace, + recommended_action=TriageAction.UPGRADE, + enabled=True, + request_template=request_template, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + make_product_triage_ruleset(self.product, ruleset=ruleset) + evaluate_ruleset(ruleset, self.product) + record = TriageRecord.objects.get() + self.assertIsNotNone(record.request) + + url = ruleset.get_admin_url() + data = { + "name": ruleset.name, + "precedence": ruleset.precedence, + "recommended_action": TriageAction.UPGRADE, + # "enabled" omitted: disables the ruleset. + **RULE_CONFIG_DATA, + } + + response = self.client.post(url, data) + + self.assertEqual(302, response.status_code) + record.refresh_from_db() + self.assertEqual(vulnerability, record.vulnerability) + + def test_disabling_then_reenabling_a_ruleset_reuses_the_existing_request(self): + # Regression: disabling then re-enabling a ruleset used to reopen a new Request + # instead of reconnecting to the one already tracking this vulnerability. + make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + requester = create_user("requester", self.dataspace) + request_template = RequestTemplate.objects.create( + name="Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=requester, + ) + ruleset = make_triage_ruleset( + self.dataspace, + recommended_action=TriageAction.UPGRADE, + enabled=True, + request_template=request_template, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + make_product_triage_ruleset(self.product, ruleset=ruleset) + evaluate_ruleset(ruleset, self.product) + original_request = TriageRecord.objects.get().request + self.assertIsNotNone(original_request) + + url = ruleset.get_admin_url() + base_data = { + "name": ruleset.name, + "precedence": ruleset.precedence, + "recommended_action": TriageAction.UPGRADE, + **RULE_CONFIG_DATA, + } + self.client.post(url, base_data) # Disable. + self.client.post(url, {**base_data, "enabled": "on"}) # Re-enable. + + self.assertEqual(1, ruleset.triage_records.count()) + self.assertEqual(original_request, TriageRecord.objects.get().request) diff --git a/vulnerabilities/triage/tests/test_commands.py b/vulnerabilities/triage/tests/test_commands.py new file mode 100644 index 00000000..11aea88f --- /dev/null +++ b/vulnerabilities/triage/tests/test_commands.py @@ -0,0 +1,133 @@ +# +# 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 io import StringIO +from unittest import mock + +from django.core import management +from django.core.management.base import CommandError +from django.test import TestCase + +from component_catalog.tests import make_package +from dje.models import Dataspace +from product_portfolio.tests import make_product +from product_portfolio.tests import make_product_package +from vulnerabilities.tests import make_vulnerability +from vulnerabilities.triage.models import AnalysisPreset +from vulnerabilities.triage.models import TriageRecord +from vulnerabilities.triage.models import TriageRuleset +from vulnerabilities.triage.tests import make_product_triage_ruleset +from vulnerabilities.triage.tests import make_triage_ruleset + + +class CreateTriageRulesetsCommandTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + + def test_raises_for_a_missing_dataspace(self): + with self.assertRaises(CommandError) as error: + management.call_command("create_triage_rulesets", "does-not-exist") + self.assertEqual('Dataspace "does-not-exist" does not exist.', str(error.exception)) + + def test_creates_the_reference_rulesets_and_presets(self): + management.call_command("create_triage_rulesets", self.dataspace.name, stdout=StringIO()) + + self.assertEqual(7, TriageRuleset.objects.filter(dataspace=self.dataspace).count()) + self.assertEqual(3, AnalysisPreset.objects.filter(dataspace=self.dataspace).count()) + + def test_raises_when_rulesets_already_exist_without_reset(self): + management.call_command("create_triage_rulesets", self.dataspace.name, stdout=StringIO()) + + with self.assertRaises(CommandError) as error: + management.call_command( + "create_triage_rulesets", self.dataspace.name, stdout=StringIO() + ) + expected = ( + f'Dataspace "{self.dataspace.name}" already has triage rulesets.' + " Use --reset to delete and recreate them." + ) + self.assertEqual(expected, str(error.exception)) + + def test_reset_with_noinput_deletes_and_recreates_without_prompting(self): + management.call_command("create_triage_rulesets", self.dataspace.name, stdout=StringIO()) + ruleset_count = TriageRuleset.objects.filter(dataspace=self.dataspace).count() + + management.call_command( + "create_triage_rulesets", + self.dataspace.name, + "--reset", + "--noinput", + stdout=StringIO(), + ) + + new_count = TriageRuleset.objects.filter(dataspace=self.dataspace).count() + self.assertEqual(ruleset_count, new_count) + + @mock.patch("builtins.input") + def test_reset_cancelled_when_prompt_is_declined(self, mock_input): + mock_input.return_value = "no" + management.call_command("create_triage_rulesets", self.dataspace.name, stdout=StringIO()) + + out = StringIO() + management.call_command( + "create_triage_rulesets", self.dataspace.name, "--reset", stdout=out + ) + + self.assertIn("Reset cancelled.", out.getvalue()) + self.assertEqual(7, TriageRuleset.objects.filter(dataspace=self.dataspace).count()) + + def test_links_each_preset_to_its_ruleset(self): + management.call_command("create_triage_rulesets", self.dataspace.name, stdout=StringIO()) + + ruleset = TriageRuleset.objects.get( + dataspace=self.dataspace, name="Dev-Only Vulnerable Package" + ) + self.assertEqual("Auto-Close - Dev Only", ruleset.analysis_preset.name) + + +class EvaluateTriageCommandTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + make_product_package(self.product, package=self.package) + + def test_raises_for_a_missing_dataspace(self): + with self.assertRaises(CommandError) as error: + management.call_command("evaluate_triage", "does-not-exist") + self.assertEqual('Dataspace "does-not-exist" does not exist.', str(error.exception)) + + def test_reports_no_active_assignments(self): + out = StringIO() + management.call_command("evaluate_triage", self.dataspace.name, stdout=out) + self.assertIn("No active ruleset assignments found.", out.getvalue()) + + def test_evaluates_the_active_assignments(self): + vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, + enabled=True, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + make_product_triage_ruleset(self.product, ruleset=ruleset) + + out = StringIO() + management.call_command("evaluate_triage", self.dataspace.name, stdout=out) + + record = TriageRecord.objects.get() + self.assertEqual(vulnerability, record.vulnerability) + self.assertIn("Evaluated: 1/1", out.getvalue()) + + def test_skips_assignments_of_a_disabled_ruleset(self): + ruleset = make_triage_ruleset(self.dataspace, enabled=False) + make_product_triage_ruleset(self.product, ruleset=ruleset) + + out = StringIO() + management.call_command("evaluate_triage", self.dataspace.name, stdout=out) + + self.assertIn("No active ruleset assignments found.", out.getvalue()) diff --git a/vulnerabilities/triage/tests/test_engine.py b/vulnerabilities/triage/tests/test_engine.py index 8d59e672..e05c120a 100644 --- a/vulnerabilities/triage/tests/test_engine.py +++ b/vulnerabilities/triage/tests/test_engine.py @@ -6,6 +6,8 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +from unittest.mock import patch + from django.contrib.contenttypes.models import ContentType from django.test import TestCase @@ -22,12 +24,17 @@ from vulnerabilities.triage.engine import collect_matches from vulnerabilities.triage.engine import create_triage_requests from vulnerabilities.triage.engine import delete_preset_analyses_for_product +from vulnerabilities.triage.engine import delete_triage_records_for_assignment +from vulnerabilities.triage.engine import evaluate_assignments from vulnerabilities.triage.engine import evaluate_ruleset +from vulnerabilities.triage.engine import reevaluate_product_rulesets from vulnerabilities.triage.engine import sync_triage_records from vulnerabilities.triage.models import AnalysisPreset +from vulnerabilities.triage.models import ProductTriageRuleset from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRecord from vulnerabilities.triage.tests import make_analysis_preset +from vulnerabilities.triage.tests import make_product_triage_ruleset from vulnerabilities.triage.tests import make_triage_ruleset from workflow.models import Request from workflow.models import RequestTemplate @@ -409,3 +416,172 @@ def test_removes_the_record_once_the_vulnerability_no_longer_matches(self): evaluate_ruleset(ruleset, self.product) self.assertFalse(TriageRecord.objects.exists()) + + +class ReevaluateProductRulesetsTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + @patch("vulnerabilities.triage.engine.evaluate_ruleset") + def test_evaluates_every_enabled_ruleset_assigned_to_the_product(self, mock_evaluate): + ruleset = make_triage_ruleset(self.dataspace, enabled=True) + make_product_triage_ruleset(self.product, ruleset=ruleset) + + reevaluate_product_rulesets(self.product) + + mock_evaluate.assert_called_once_with( + ruleset=ruleset, product=self.product, apply_preset=True + ) + + @patch("vulnerabilities.triage.engine.evaluate_ruleset") + def test_skips_disabled_ruleset_assignments(self, mock_evaluate): + ruleset = make_triage_ruleset(self.dataspace, enabled=False) + make_product_triage_ruleset(self.product, ruleset=ruleset) + + reevaluate_product_rulesets(self.product) + + mock_evaluate.assert_not_called() + + @patch("vulnerabilities.triage.engine.evaluate_ruleset") + def test_apply_preset_flag_is_forwarded(self, mock_evaluate): + ruleset = make_triage_ruleset(self.dataspace, enabled=True) + make_product_triage_ruleset(self.product, ruleset=ruleset) + + reevaluate_product_rulesets(self.product, apply_preset=False) + + mock_evaluate.assert_called_once_with( + ruleset=ruleset, product=self.product, apply_preset=False + ) + + +class EvaluateAssignmentsTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + + def test_evaluates_each_assignment_and_returns_the_evaluated_count(self): + product = make_product(self.dataspace) + package = make_package(self.dataspace) + make_product_package(product, package=package) + vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + make_product_triage_ruleset(product, ruleset=ruleset) + + evaluated_count = evaluate_assignments(ProductTriageRuleset.objects.all()) + + self.assertEqual(1, evaluated_count) + record = TriageRecord.objects.get() + self.assertEqual(vulnerability, record.vulnerability) + + @patch("vulnerabilities.triage.engine.evaluate_ruleset") + def test_continues_evaluating_remaining_assignments_after_one_raises(self, mock_evaluate): + product1 = make_product(self.dataspace, name="a-product") + product2 = make_product(self.dataspace, name="b-product") + ruleset = make_triage_ruleset(self.dataspace) + make_product_triage_ruleset(product1, ruleset=ruleset) + make_product_triage_ruleset(product2, ruleset=ruleset) + mock_evaluate.side_effect = [Exception("boom"), None] + assignments = ProductTriageRuleset.objects.order_by("product__name") + + with self.assertLogs("vulnerabilities.triage.engine", level="ERROR") as captured: + evaluated_count = evaluate_assignments(assignments) + + self.assertEqual(2, mock_evaluate.call_count) + self.assertEqual(1, evaluated_count) + self.assertTrue(any("Triage evaluation failed" in line for line in captured.output)) + + +class DeleteTriageRecordsForAssignmentTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + self.product_package = make_product_package(self.product, package=self.package) + self.vulnerability = make_vulnerability( + self.dataspace, affecting=self.package, risk_score=9.0 + ) + self.ruleset = make_triage_ruleset( + self.dataspace, + recommended_action=TriageAction.NOTIFY, + rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, + ) + evaluate_ruleset(self.ruleset, self.product) + self.assertTrue(TriageRecord.objects.exists()) + + def test_deletes_the_triage_records_for_the_product(self): + delete_triage_records_for_assignment(ruleset=self.ruleset, product=self.product) + self.assertFalse(TriageRecord.objects.exists()) + + def test_keeps_a_record_that_has_an_open_request(self): + requester = create_user("requester", self.dataspace) + request_template = RequestTemplate.objects.create( + name="Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=requester, + ) + request = request_template.create_request( + requester=requester, + title="Vulnerability request", + product_context=self.product, + object_id=self.product.pk, + ) + record = TriageRecord.objects.get() + record.request = request + record.save() + + delete_triage_records_for_assignment(ruleset=self.ruleset, product=self.product) + + record.refresh_from_db() + self.assertEqual(request, record.request) + + def test_reassigning_the_ruleset_reuses_the_existing_request(self): + # Regression: unassigning then reassigning a ruleset used to reopen a new Request + # instead of reconnecting to the one already tracking this vulnerability. + requester = create_user("requester", self.dataspace) + request_template = RequestTemplate.objects.create( + name="Template", + description="Header", + dataspace=self.dataspace, + content_type=ContentType.objects.get_for_model(Product), + created_by=requester, + ) + self.ruleset.request_template = request_template + self.ruleset.save() + evaluate_ruleset(self.ruleset, self.product) + original_request = TriageRecord.objects.get().request + self.assertIsNotNone(original_request) + + delete_triage_records_for_assignment(ruleset=self.ruleset, product=self.product) + evaluate_ruleset(self.ruleset, self.product) + + self.assertEqual(1, Request.objects.count()) + self.assertEqual(original_request, TriageRecord.objects.get().request) + + def test_does_not_delete_records_belonging_to_another_product(self): + other_product = make_product(self.dataspace) + make_product_package(other_product, package=self.package) + evaluate_ruleset(self.ruleset, other_product) + other_record = TriageRecord.objects.get(product=other_product) + + delete_triage_records_for_assignment(ruleset=self.ruleset, product=self.product) + + self.assertEqual([other_record], list(TriageRecord.objects.all())) + + def test_keeps_the_preset_analyses_tied_to_the_deleted_records(self): + # The facts that justified the analysis have not changed, only the decision to keep + # monitoring with this ruleset (matches disabling a ruleset, which also leaves + # previously-applied analyses in place). + preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) + self.ruleset.analysis_preset = preset + self.ruleset.save() + evaluate_ruleset(self.ruleset, self.product) + self.assertTrue(VulnerabilityAnalysis.objects.exists()) + + delete_triage_records_for_assignment(ruleset=self.ruleset, product=self.product) + + self.assertTrue(VulnerabilityAnalysis.objects.exists()) diff --git a/vulnerabilities/triage/tests/test_forms.py b/vulnerabilities/triage/tests/test_forms.py index 16935a18..8ec9c700 100644 --- a/vulnerabilities/triage/tests/test_forms.py +++ b/vulnerabilities/triage/tests/test_forms.py @@ -91,7 +91,7 @@ def test_save_keeps_an_inactive_rule_that_has_parameters_with_their_defaults(sel def test_rejects_a_request_template_with_no_creator(self): # A RequestTemplate normally always has a creator (the admin form sets it on - # addition), but nothing at the DB level guarantees it -- reject it here rather + # addition), but nothing at the DB level guarantees it, reject it here rather # than let create_triage_requests crash later with an IntegrityError. request_template = RequestTemplate.objects.create( name="Broken Template", diff --git a/vulnerabilities/triage/tests/test_signals.py b/vulnerabilities/triage/tests/test_signals.py index e180fddd..ae15f790 100644 --- a/vulnerabilities/triage/tests/test_signals.py +++ b/vulnerabilities/triage/tests/test_signals.py @@ -8,331 +8,18 @@ from unittest.mock import patch -from django.contrib.contenttypes.models import ContentType from django.test import TestCase from component_catalog.tests import make_package from dje.models import Dataspace -from dje.tests import create_user -from product_portfolio.models import Product from product_portfolio.tests import make_product from product_portfolio.tests import make_product_package from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.tests import make_vulnerability from vulnerabilities.tests import make_vulnerability_analysis from vulnerabilities.triage.engine import apply_preset_for_vulnerabilities -from vulnerabilities.triage.engine import evaluate_ruleset from vulnerabilities.triage.models import AnalysisPreset -from vulnerabilities.triage.models import TriageAction -from vulnerabilities.triage.models import TriageRecord -from vulnerabilities.triage.signals import reevaluate_product_rulesets from vulnerabilities.triage.tests import make_analysis_preset -from vulnerabilities.triage.tests import make_product_triage_ruleset -from vulnerabilities.triage.tests import make_triage_ruleset -from workflow.models import Request -from workflow.models import RequestTemplate - - -class ReevaluateProductRulesetsTestCase(TestCase): - def setUp(self): - self.dataspace = Dataspace.objects.create(name="nexB") - self.product = make_product(self.dataspace) - - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_evaluates_every_enabled_ruleset_assigned_to_the_product(self, mock_evaluate): - ruleset = make_triage_ruleset(self.dataspace, enabled=True) - make_product_triage_ruleset(self.product, ruleset=ruleset) - mock_evaluate.reset_mock() # Called once already by the evaluate_on_assign signal. - - reevaluate_product_rulesets(self.product) - - mock_evaluate.assert_called_once_with( - ruleset=ruleset, product=self.product, apply_preset=True - ) - - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_skips_disabled_ruleset_assignments(self, mock_evaluate): - ruleset = make_triage_ruleset(self.dataspace, enabled=False) - make_product_triage_ruleset(self.product, ruleset=ruleset) - - reevaluate_product_rulesets(self.product) - - mock_evaluate.assert_not_called() - - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_apply_preset_flag_is_forwarded(self, mock_evaluate): - ruleset = make_triage_ruleset(self.dataspace, enabled=True) - make_product_triage_ruleset(self.product, ruleset=ruleset) - mock_evaluate.reset_mock() # Called once already by the evaluate_on_assign signal. - - reevaluate_product_rulesets(self.product, apply_preset=False) - - mock_evaluate.assert_called_once_with( - ruleset=ruleset, product=self.product, apply_preset=False - ) - - -class TriageRulesetSaveSignalTestCase(TestCase): - def setUp(self): - self.dataspace = Dataspace.objects.create(name="nexB") - self.product = make_product(self.dataspace) - - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_creating_a_ruleset_does_not_trigger_evaluation(self, mock_evaluate): - make_triage_ruleset(self.dataspace) - mock_evaluate.assert_not_called() - - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_updating_an_enabled_assigned_ruleset_reevaluates_its_products(self, mock_evaluate): - ruleset = make_triage_ruleset(self.dataspace) - make_product_triage_ruleset(self.product, ruleset=ruleset) - mock_evaluate.reset_mock() - - ruleset.description = "updated" - ruleset.save() - - mock_evaluate.assert_called_once_with(ruleset=ruleset, product=self.product) - - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_updating_an_unassigned_ruleset_does_not_trigger_evaluation(self, mock_evaluate): - ruleset = make_triage_ruleset(self.dataspace) - mock_evaluate.reset_mock() - - ruleset.description = "updated" - ruleset.save() - - mock_evaluate.assert_not_called() - - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_disabling_a_ruleset_deletes_its_triage_records_instead_of_evaluating( - self, mock_evaluate - ): - package = make_package(self.dataspace) - make_product_package(self.product, package=package) - vulnerability = make_vulnerability(self.dataspace, affecting=package) - ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.NOTIFY) - make_product_triage_ruleset(self.product, ruleset=ruleset) - TriageRecord.objects.create( - vulnerability=vulnerability, - product=self.product, - ruleset=ruleset, - recommended_action=ruleset.recommended_action, - dataspace=self.dataspace, - ) - mock_evaluate.reset_mock() - - ruleset.enabled = False - ruleset.save() - - self.assertFalse(TriageRecord.objects.exists()) - mock_evaluate.assert_not_called() - - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_disabling_a_ruleset_keeps_records_that_have_an_open_request(self, mock_evaluate): - package = make_package(self.dataspace) - make_product_package(self.product, package=package) - vulnerability = make_vulnerability(self.dataspace, affecting=package) - ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.NOTIFY) - make_product_triage_ruleset(self.product, ruleset=ruleset) - requester = create_user("requester", self.dataspace) - request_template = RequestTemplate.objects.create( - name="Template", - description="Header", - dataspace=self.dataspace, - content_type=ContentType.objects.get_for_model(Product), - created_by=requester, - ) - request = request_template.create_request( - requester=requester, - title="Vulnerability request", - product_context=self.product, - object_id=self.product.pk, - ) - record = TriageRecord.objects.create( - vulnerability=vulnerability, - product=self.product, - ruleset=ruleset, - recommended_action=ruleset.recommended_action, - request=request, - dataspace=self.dataspace, - ) - mock_evaluate.reset_mock() - - ruleset.enabled = False - ruleset.save() - - record.refresh_from_db() - self.assertEqual(request, record.request) - - def test_disabling_then_reenabling_a_ruleset_reuses_the_existing_request(self): - # Regression: disabling then re-enabling a ruleset used to reopen a new Request - # instead of reconnecting to the one already tracking this vulnerability. - package = make_package(self.dataspace) - make_product_package(self.product, package=package) - make_vulnerability(self.dataspace, affecting=package, risk_score=9.0) - requester = create_user("requester", self.dataspace) - request_template = RequestTemplate.objects.create( - name="Template", - description="Header", - dataspace=self.dataspace, - content_type=ContentType.objects.get_for_model(Product), - created_by=requester, - ) - ruleset = make_triage_ruleset( - self.dataspace, - recommended_action=TriageAction.NOTIFY, - request_template=request_template, - rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, - ) - make_product_triage_ruleset(self.product, ruleset=ruleset) - evaluate_ruleset(ruleset, self.product) - original_request = TriageRecord.objects.get().request - self.assertIsNotNone(original_request) - - ruleset.enabled = False - ruleset.save() - ruleset.enabled = True - ruleset.save() - - self.assertEqual(1, Request.objects.count()) - self.assertEqual(original_request, TriageRecord.objects.get().request) - - -class EvaluateOnAssignSignalTestCase(TestCase): - def setUp(self): - self.dataspace = Dataspace.objects.create(name="nexB") - self.product = make_product(self.dataspace) - - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_assigning_an_enabled_ruleset_evaluates_it(self, mock_evaluate): - ruleset = make_triage_ruleset(self.dataspace, enabled=True) - - make_product_triage_ruleset(self.product, ruleset=ruleset) - - mock_evaluate.assert_called_once_with(ruleset=ruleset, product=self.product) - - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_assigning_a_disabled_ruleset_does_not_evaluate(self, mock_evaluate): - ruleset = make_triage_ruleset(self.dataspace, enabled=False) - - make_product_triage_ruleset(self.product, ruleset=ruleset) - - mock_evaluate.assert_not_called() - - @patch("vulnerabilities.triage.signals.evaluate_ruleset") - def test_resaving_an_existing_assignment_does_not_reevaluate(self, mock_evaluate): - ruleset = make_triage_ruleset(self.dataspace, enabled=True) - assignment = make_product_triage_ruleset(self.product, ruleset=ruleset) - mock_evaluate.reset_mock() - - assignment.save() - - mock_evaluate.assert_not_called() - - -class DeleteTriageRecordsOnUnassignSignalTestCase(TestCase): - def setUp(self): - self.dataspace = Dataspace.objects.create(name="nexB") - self.product = make_product(self.dataspace) - self.package = make_package(self.dataspace) - self.product_package = make_product_package(self.product, package=self.package) - self.vulnerability = make_vulnerability( - self.dataspace, affecting=self.package, risk_score=9.0 - ) - self.ruleset = make_triage_ruleset( - self.dataspace, - recommended_action=TriageAction.NOTIFY, - rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}}, - ) - self.assignment = make_product_triage_ruleset(self.product, ruleset=self.ruleset) - evaluate_ruleset(self.ruleset, self.product) - self.assertTrue(TriageRecord.objects.exists()) - - def test_unassigning_the_ruleset_deletes_its_triage_records_for_the_product(self): - self.assignment.delete() - self.assertFalse(TriageRecord.objects.exists()) - - def test_unassigning_keeps_a_record_that_has_an_open_request(self): - requester = create_user("requester", self.dataspace) - request_template = RequestTemplate.objects.create( - name="Template", - description="Header", - dataspace=self.dataspace, - content_type=ContentType.objects.get_for_model(Product), - created_by=requester, - ) - request = request_template.create_request( - requester=requester, - title="Vulnerability request", - product_context=self.product, - object_id=self.product.pk, - ) - record = TriageRecord.objects.get() - record.request = request - record.save() - - self.assignment.delete() - - record.refresh_from_db() - self.assertEqual(request, record.request) - - def test_reassigning_the_ruleset_reuses_the_existing_request(self): - # Regression: unassigning then reassigning a ruleset used to reopen a new Request - # instead of reconnecting to the one already tracking this vulnerability. - requester = create_user("requester", self.dataspace) - request_template = RequestTemplate.objects.create( - name="Template", - description="Header", - dataspace=self.dataspace, - content_type=ContentType.objects.get_for_model(Product), - created_by=requester, - ) - self.ruleset.request_template = request_template - self.ruleset.save() - original_request = TriageRecord.objects.get().request - self.assertIsNotNone(original_request) - - self.assignment.delete() - make_product_triage_ruleset(self.product, ruleset=self.ruleset) - evaluate_ruleset(self.ruleset, self.product) - - self.assertEqual(1, Request.objects.count()) - self.assertEqual(original_request, TriageRecord.objects.get().request) - - def test_does_not_delete_records_belonging_to_another_product(self): - other_product = make_product(self.dataspace) - make_product_package(other_product, package=self.package) - make_product_triage_ruleset(other_product, ruleset=self.ruleset) - evaluate_ruleset(self.ruleset, other_product) - other_record = TriageRecord.objects.get(product=other_product) - - self.assignment.delete() - - self.assertEqual([other_record], list(TriageRecord.objects.all())) - - def test_deletes_the_preset_analyses_tied_to_the_deleted_records(self): - preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) - self.ruleset.analysis_preset = preset - self.ruleset.save() # Re-evaluates the assigned product and auto-applies the preset. - self.assertTrue(VulnerabilityAnalysis.objects.exists()) - - self.assignment.delete() - - self.assertFalse(VulnerabilityAnalysis.objects.exists()) - - def test_does_not_delete_human_owned_analyses(self): - # A human analysis set before the preset exists is never auto-applied over, so it - # keeps applied_by_preset=None and must survive the preset cleanup below. - make_vulnerability_analysis(self.product_package, self.vulnerability, state="exploitable") - preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) - self.ruleset.analysis_preset = preset - self.ruleset.save() - - self.assignment.delete() - - analysis = VulnerabilityAnalysis.objects.get() - self.assertEqual("exploitable", analysis.state) - self.assertIsNone(analysis.applied_by_preset) class ReevaluateOnAnalysisChangeSignalTestCase(TestCase): @@ -370,9 +57,10 @@ def test_deleting_a_human_owned_analysis_reevaluates_with_preset_application_dis mock_reevaluate.assert_called_once_with(self.product, apply_preset=False) @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets") - def test_deleting_a_preset_owned_analysis_reevaluates_with_preset_application_enabled( - self, mock_reevaluate - ): + def test_deleting_a_preset_owned_analysis_does_not_reevaluate(self, mock_reevaluate): + # A write or delete made by the engine itself always happens inside an evaluation + # pass that already covers every ruleset assigned to the product, so re-triggering + # here would recursively re-run that same pass. preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED) apply_preset_for_vulnerabilities(preset, self.product, [self.vulnerability.pk]) analysis = VulnerabilityAnalysis.objects.get() @@ -380,7 +68,7 @@ def test_deleting_a_preset_owned_analysis_reevaluates_with_preset_application_en analysis.delete() - mock_reevaluate.assert_called_once_with(self.product, apply_preset=True) + mock_reevaluate.assert_not_called() class ReevaluateOnProductPackageChangeSignalTestCase(TestCase): diff --git a/vulnerabilities/triage/tests/test_tasks.py b/vulnerabilities/triage/tests/test_tasks.py index 46b7f01a..f6191080 100644 --- a/vulnerabilities/triage/tests/test_tasks.py +++ b/vulnerabilities/triage/tests/test_tasks.py @@ -6,67 +6,92 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # -from unittest.mock import patch +import uuid from django.test import TestCase +from component_catalog.tests import make_package from dje.models import Dataspace from product_portfolio.tests import make_product +from product_portfolio.tests import make_product_package +from vulnerabilities.tests import make_vulnerability +from vulnerabilities.triage.models import TriageRecord from vulnerabilities.triage.tasks import evaluate_all_products_vulnerability_triage_task +from vulnerabilities.triage.tasks import reevaluate_product_triage_rulesets_task from vulnerabilities.triage.tests import make_product_triage_ruleset from vulnerabilities.triage.tests import make_triage_ruleset +RISK_SCORE_RULE_CONFIG = {"risk_score": {"is_active": True, "min_risk_score": 8.0}} + class EvaluateAllProductsVulnerabilityTriageTaskTestCase(TestCase): def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + make_product_package(self.product, package=self.package) - @patch("vulnerabilities.triage.tasks.evaluate_ruleset") - def test_evaluates_every_assignment_of_an_enabled_ruleset(self, mock_evaluate): - product = make_product(self.dataspace) - ruleset = make_triage_ruleset(self.dataspace, enabled=True) - make_product_triage_ruleset(product, ruleset=ruleset) + def test_evaluates_every_assignment_of_an_enabled_ruleset(self): + vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, enabled=True, rules_config=RISK_SCORE_RULE_CONFIG + ) + make_product_triage_ruleset(self.product, ruleset=ruleset) evaluate_all_products_vulnerability_triage_task() - mock_evaluate.assert_called_once_with(ruleset=ruleset, product=product) + record = TriageRecord.objects.get() + self.assertEqual(vulnerability, record.vulnerability) - @patch("vulnerabilities.triage.tasks.evaluate_ruleset") - def test_skips_assignments_of_a_disabled_ruleset(self, mock_evaluate): - product = make_product(self.dataspace) - ruleset = make_triage_ruleset(self.dataspace, enabled=False) - make_product_triage_ruleset(product, ruleset=ruleset) + def test_skips_assignments_of_a_disabled_ruleset(self): + make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, enabled=False, rules_config=RISK_SCORE_RULE_CONFIG + ) + make_product_triage_ruleset(self.product, ruleset=ruleset) evaluate_all_products_vulnerability_triage_task() - mock_evaluate.assert_not_called() + self.assertFalse(TriageRecord.objects.exists()) - @patch("vulnerabilities.triage.tasks.evaluate_ruleset") - def test_continues_evaluating_remaining_assignments_after_one_raises(self, mock_evaluate): - failing_product = make_product(self.dataspace, name="a-product") - ok_product = make_product(self.dataspace, name="b-product") - ruleset = make_triage_ruleset(self.dataspace) - make_product_triage_ruleset(failing_product, ruleset=ruleset) - make_product_triage_ruleset(ok_product, ruleset=ruleset) - mock_evaluate.side_effect = [Exception("boom"), None] + def test_evaluates_assignments_across_multiple_products(self): + other_product = make_product(self.dataspace) + other_package = make_package(self.dataspace) + make_product_package(other_product, package=other_package) + make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + make_vulnerability(self.dataspace, affecting=other_package, risk_score=9.0) + ruleset = make_triage_ruleset(self.dataspace, rules_config=RISK_SCORE_RULE_CONFIG) + make_product_triage_ruleset(self.product, ruleset=ruleset) + make_product_triage_ruleset(other_product, ruleset=ruleset) - with self.assertLogs("vulnerabilities.triage.tasks", level="ERROR") as captured: - evaluate_all_products_vulnerability_triage_task() + evaluate_all_products_vulnerability_triage_task() - self.assertEqual(2, mock_evaluate.call_count) - mock_evaluate.assert_any_call(ruleset=ruleset, product=failing_product) - mock_evaluate.assert_any_call(ruleset=ruleset, product=ok_product) - self.assertTrue(any("Triage evaluation failed" in line for line in captured.output)) + evaluated_product_ids = set(TriageRecord.objects.values_list("product_id", flat=True)) + self.assertEqual({self.product.id, other_product.id}, evaluated_product_ids) - @patch("vulnerabilities.triage.tasks.evaluate_ruleset") - def test_evaluates_assignments_across_multiple_products(self, mock_evaluate): - product1 = make_product(self.dataspace) - product2 = make_product(self.dataspace) - ruleset = make_triage_ruleset(self.dataspace) - make_product_triage_ruleset(product1, ruleset=ruleset) - make_product_triage_ruleset(product2, ruleset=ruleset) - evaluate_all_products_vulnerability_triage_task() +class ReevaluateProductTriageRulesetsTaskTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + make_product_package(self.product, package=self.package) + + def test_evaluates_the_rulesets_assigned_to_the_product(self): + vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0) + ruleset = make_triage_ruleset( + self.dataspace, enabled=True, rules_config=RISK_SCORE_RULE_CONFIG + ) + make_product_triage_ruleset(self.product, ruleset=ruleset) + + reevaluate_product_triage_rulesets_task(product_uuid=str(self.product.uuid)) + + record = TriageRecord.objects.get() + self.assertEqual(vulnerability, record.vulnerability) + + def test_logs_and_returns_when_the_product_does_not_exist(self): + missing_uuid = str(uuid.uuid4()) + with self.assertLogs("vulnerabilities.triage.tasks", level="ERROR") as captured: + reevaluate_product_triage_rulesets_task(product_uuid=missing_uuid) - evaluated_products = [call.kwargs["product"] for call in mock_evaluate.call_args_list] - self.assertEqual({product1, product2}, set(evaluated_products)) + self.assertTrue(any("not found" in line for line in captured.output))