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