From a7d7929e2d1ddca7db922d0bc3fd0aba4191246f Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 30 Jul 2026 17:47:31 +0400 Subject: [PATCH 01/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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.