diff --git a/component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html b/component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html
new file mode 100644
index 00000000..667c2086
--- /dev/null
+++ b/component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html
@@ -0,0 +1,34 @@
+
{% include 'includes/filters_breadcrumbs.html' with filterset=filterset fragment=tab_id only %}
diff --git a/dje/tests/test_forms.py b/dje/tests/test_forms.py
index ed21e465..2bd70bdd 100644
--- a/dje/tests/test_forms.py
+++ b/dje/tests/test_forms.py
@@ -110,7 +110,7 @@ def test_tabs_permission_formset_load_perms(self):
def test_copy_defaults_form_get_all_dataspaced_models(self):
dataspaced_models = CopyDefaultsForm.get_all_dataspaced_models()
- self.assertEqual(10, len(dataspaced_models))
+ self.assertEqual(11, len(dataspaced_models))
self.assertEqual(7, len(dataspaced_models.get("Component Catalog")))
self.assertIn("Subcomponent", str(dataspaced_models.get("Component Catalog")))
diff --git a/product_portfolio/admin.py b/product_portfolio/admin.py
index 36ece772..ccb1789d 100644
--- a/product_portfolio/admin.py
+++ b/product_portfolio/admin.py
@@ -72,6 +72,7 @@
from product_portfolio.models import ProductRelationStatus
from product_portfolio.models import ProductStatus
from reporting.filters import ReportingQueryListFilter
+from vulnerabilities.triage.models import ProductTriageRuleset
@admin.register(ProductStatus, site=dejacode_site)
@@ -240,7 +241,7 @@ def save_model(self, request, obj, form, change):
if "_saveasnew" in request.POST:
old_product_id = request.resolver_match.kwargs.get("object_id")
old_product = self.get_object(request, old_product_id)
- for model_class in [ProductComponent, ProductPackage]:
+ for model_class in [ProductComponent, ProductPackage, ProductTriageRuleset]:
for relationship in model_class.objects.filter(product=old_product):
relationship.id = None
relationship.uuid = uuid.uuid4()
diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py
index 06134052..5c40ff65 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):
@@ -366,6 +368,7 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet):
dropdown_fields = [
"is_modified",
"weighted_risk_score",
+ "triage_action",
"vulnerability_analyses__state",
"vulnerability_analyses__justification",
"responses",
@@ -419,6 +422,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,
@@ -438,6 +447,17 @@ class Meta:
"exploitability",
]
+ @staticmethod
+ def filter_triage_action(queryset, name, value):
+ if not value:
+ return queryset
+ primary_triage = TriageRecord.objects.highest_precedence().filter(
+ product=OuterRef("product"),
+ vulnerability__affected_packages__productpackages=OuterRef("pk"),
+ recommended_action=value,
+ )
+ return queryset.filter(Exists(primary_triage)).distinct()
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.filters["vulnerability_analyses__state"].extra["null_label"] = "(No values)"
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/includes/manage_triage_rules_nav_item.html b/product_portfolio/templates/product_portfolio/includes/manage_triage_rules_nav_item.html
new file mode 100644
index 00000000..39b21042
--- /dev/null
+++ b/product_portfolio/templates/product_portfolio/includes/manage_triage_rules_nav_item.html
@@ -0,0 +1,6 @@
+{% load i18n %}
+
+
+ {% trans "Manage triage rules" %}
+
+
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..51a4aed6
--- /dev/null
+++ b/product_portfolio/templates/product_portfolio/modals/manage_triage_rulesets_form.html
@@ -0,0 +1,61 @@
+{% load i18n %}
+
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..b093ccf8 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,40 @@
{% endif %}
+ {% if has_change_permission and request.user.dataspace.enable_vulnerablecodedb_access %}
+
+ {% endif %}
+
{% if request.user.dataspace.enable_vulnerablecodedb_access and product.vulnerability_count %}
{% endif %}
diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html
index 43ed66a3..9c886d78 100644
--- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html
+++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html
@@ -1,7 +1,7 @@
{% load i18n %}
{% load as_icon from dje_tags %}
-{% include 'tabs/pagination.html' with search_verbose_name="packages" %}
+{% include 'tabs/pagination.html' with search_verbose_name="packages" extra_nav_item_template=manage_triage_rules_nav_item_template %}
{% if risk_threshold %}
@@ -14,33 +14,36 @@
{% include 'includes/object_list_table_header.html' with filter=filterset include_actions=True %}
{% for product_package in page_obj.object_list %}
+ {% if product_package.display_vulnerabilities %}
-
+
{{ 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 %}
-
+ {% endwith %}
+ {% if product_package.is_deployed %}
+
{% trans "Deployed" %}
+ {% endif %}
+ {% if product_package.purpose %}
+
{{ product_package.purpose.label }}
+ {% endif %}
+
+ {% if product_package.purpose.exposure_factor %}
+
+ {% trans "Exposure factor:" %} {{ product_package.purpose.exposure_factor }}
{% endif %}
-
- {% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=product_package.weighted_risk_score only %}
-
- {% for vulnerability in product_package.package.affected_by_vulnerabilities.all %}
+ {% for vulnerability in product_package.display_vulnerabilities %}
{% if not forloop.first %} {% endif %}
-
+
{% if vulnerability.resource_url %}
@@ -50,73 +53,148 @@
{% else %}
{{ vulnerability.advisory_id }}
{% endif %}
- {% if vulnerability.summary %}
-
-
+ {% if vulnerability.summary or vulnerability.aliases or vulnerability.exploitability or vulnerability.risk_score %}
+
+
{% 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 %}
-
- {% if vulnerability.aliases %}
-
- {% include 'component_catalog/includes/vulnerability_aliases.html' with aliases=vulnerability.aliases only %}
-
- {% endif %}
-
-
- {% if vulnerability.vulnerability_analysis %}
- {% if vulnerability.vulnerability_analysis.state %}
- {{ vulnerability.vulnerability_analysis.get_state_display }}
- {% endif %}
- {% if vulnerability.vulnerability_analysis.detail %}
-
-
+
+ {% with score=vulnerability.risk_score %}
+ {% if score %}
+
+ {% trans "Risk:" %} {{ score }}
+
+ {% endif %}
+ {% endwith %}
+ {% if vulnerability.exploitability %}
+
+ {{ vulnerability.get_exploitability_display }}
{% endif %}
- {% endif %}
-
-
- {% if vulnerability.vulnerability_analysis.justification %}
- {{ vulnerability.vulnerability_analysis.get_justification_display }}
- {% endif %}
+
+ {% if has_triage_rulesets %}
- {% if vulnerability.vulnerability_analysis.responses %}
-
- {% for response in vulnerability.vulnerability_analysis.responses %}
- {{ response }}
- {% endfor %}
-
+ {% if vulnerability.triage_record %}
+ {% with record=vulnerability.triage_record %}
+ {% if record.recommended_action %}
+
+ {{ record.action_label }}
+
+ {% endif %}
+
+ {{ record.ruleset.name }}
+
+ {% if record.request %}
+
+ {% endif %}
+
+ {% trans "Since:" %} {{ record.detected_date|date:"M j, Y" }}
+
+ {% endwith %}
{% endif %}
-
- {% if vulnerability.vulnerability_analysis.is_reachable %}
-
- {% elif vulnerability.vulnerability_analysis.is_reachable is False %}
-
- {% endif %}
+ {% endif %}
+
+ {% with analysis=vulnerability.vulnerability_analysis %}
+
+ {% if analysis %}
+
+
+ {% if analysis.state %}
+
+ {{ analysis.get_state_display }}
+
+ {% endif %}
+ {% if analysis.is_reachable %}
+
+ {% elif analysis.is_reachable is False %}
+
+ {% endif %}
+
+ {% if analysis.justification %}
+
{{ analysis.get_justification_display }}
+ {% endif %}
+ {% if analysis.detail %}
+
+ {{ analysis.detail|truncatechars:70 }}
+
+ {% endif %}
+ {% if analysis.response_labels %}
+
+ {% for response in analysis.response_labels %}
+ {{ response }}
+ {% endfor %}
+
+ {% endif %}
+
+ {% if analysis.applied_by_preset %}
+
+ {% trans "Auto-applied:" %} {{ analysis.applied_by_preset.name }}
+
+ {% elif analysis.last_modified_by or analysis.created_by %}
+
+ {{ analysis.last_modified_by|default:analysis.created_by }}
+ · {{ analysis.last_updated|date:"M j, Y" }}
+
+ {% endif %}
+ {% else %}
+
{% trans "Not analyzed" %}
+ {% endif %}
+
+ {% endwith %}
-
-
-
-
-
-
+
+
+
+ {% if vulnerability.vulnerability_analysis %}
+
+
+
+ {% else %}
+
+
+
+ {% endif %}
+
+ {% if analysis_presets and not vulnerability.vulnerability_analysis %}
+
+
+
+
+
+
+ {% endif %}
+
{% if not forloop.first %} {% endif %}
{% endfor %}
+ {% endif %}
{% empty %}
-
+
No results.
{% if filterset.is_active %}
@@ -127,4 +205,4 @@
{% endfor %}
-
\ No newline at end of file
+
diff --git a/product_portfolio/tests/test_admin.py b/product_portfolio/tests/test_admin.py
index 32836d5c..1e015173 100644
--- a/product_portfolio/tests/test_admin.py
+++ b/product_portfolio/tests/test_admin.py
@@ -25,6 +25,8 @@
from product_portfolio.models import ProductComponent
from product_portfolio.models import ProductPackage
from product_portfolio.tests import make_product_dependency
+from vulnerabilities.triage.models import ProductTriageRuleset
+from vulnerabilities.triage.models import TriageRuleset
class ProductPortfolioAdminsTestCase(TestCase):
@@ -430,6 +432,12 @@ def test_product_admin_changeform_save_as_includes_relationships(self):
ProductPackage.objects.create(
product=self.product1, package=self.package1, dataspace=self.dataspace
)
+ ruleset = TriageRuleset.objects.create(
+ name="Upgrade Ruleset", precedence=100, dataspace=self.dataspace
+ )
+ ProductTriageRuleset.objects.create(
+ product=self.product1, ruleset=ruleset, dataspace=self.dataspace
+ )
url = self.product1.get_admin_url()
data = {
@@ -447,6 +455,7 @@ def test_product_admin_changeform_save_as_includes_relationships(self):
new_product = Product.unsecured_objects.get(name=self.product1.name, version="new version")
self.assertEqual(1, new_product.productcomponents.count())
self.assertEqual(1, new_product.productpackages.count())
+ self.assertEqual(1, new_product.product_triage_rulesets.count())
def test_codebaseresource_admin_changeform_product_prefill_on_save_addanother(self):
self.client.login(username=self.user.username, password="secret")
diff --git a/product_portfolio/tests/test_filters.py b/product_portfolio/tests/test_filters.py
index 1dfa6173..7d5496b2 100644
--- a/product_portfolio/tests/test_filters.py
+++ b/product_portfolio/tests/test_filters.py
@@ -26,6 +26,10 @@
from product_portfolio.tests import make_product_component
from product_portfolio.tests import make_product_package
from vulnerabilities.tests import make_vulnerability
+from vulnerabilities.triage.engine import evaluate_ruleset
+from vulnerabilities.triage.models import ProductTriageRuleset
+from vulnerabilities.triage.models import TriageAction
+from vulnerabilities.triage.models import TriageRuleset
class ProductPackageFilterSetTestCase(TestCase):
@@ -187,6 +191,47 @@ def test_filter_by_vulnerability_rule_returns_distinct_results(self):
self.assertEqual(1, filterset.qs.count())
+class ProductPackageFilterByTriageActionTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.pp_with_recommendation = make_product_package(
+ self.product, make_package(self.dataspace)
+ )
+ self.pp_without_recommendation = make_product_package(
+ self.product, make_package(self.dataspace)
+ )
+ make_vulnerability(
+ self.dataspace, affecting=self.pp_with_recommendation.package, risk_score=9.0
+ )
+ ruleset = TriageRuleset.objects.create(
+ name="Upgrade Ruleset",
+ recommended_action=TriageAction.UPGRADE,
+ precedence=100,
+ dataspace=self.dataspace,
+ rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}},
+ )
+ ProductTriageRuleset.objects.create(
+ product=self.product, ruleset=ruleset, dataspace=self.dataspace
+ )
+ evaluate_ruleset(ruleset, self.product)
+
+ def test_filter_by_triage_action_filters_matching_packages(self):
+ filterset = ProductPackageFilterSet(
+ dataspace=self.dataspace,
+ data={"triage_action": "upgrade"},
+ )
+ self.assertIn(self.pp_with_recommendation, filterset.qs)
+ self.assertNotIn(self.pp_without_recommendation, filterset.qs)
+
+ def test_filter_by_triage_action_excludes_non_matching_action(self):
+ filterset = ProductPackageFilterSet(
+ dataspace=self.dataspace,
+ data={"triage_action": "notify"},
+ )
+ self.assertNotIn(self.pp_with_recommendation, filterset.qs)
+
+
class ProductComponentFilterByRuleTestCase(TestCase):
def setUp(self):
self.dataspace = Dataspace.objects.create(name="nexB")
diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py
index d54ccd57..7271007f 100644
--- a/product_portfolio/tests/test_views.py
+++ b/product_portfolio/tests/test_views.py
@@ -64,6 +64,10 @@
from vulnerabilities.models import VulnerabilityAnalysis
from vulnerabilities.tests import make_vulnerability
from vulnerabilities.tests import make_vulnerability_analysis
+from vulnerabilities.triage.models import AnalysisPreset
+from vulnerabilities.triage.models import ProductTriageRuleset
+from vulnerabilities.triage.models import TriageAction
+from vulnerabilities.triage.models import TriageRuleset
from workflow.models import Request
from workflow.models import RequestTemplate
@@ -278,7 +282,7 @@ def test_product_portfolio_detail_view_tab_vulnerability_queryset(self):
self.client.login(username="nexb_user", password="secret")
url = self.product1.get_url("tab_vulnerabilities")
- with self.assertMaxQueries(9):
+ with self.assertMaxQueries(12):
response = self.client.get(url)
self.assertContains(response, "0 results")
@@ -292,20 +296,31 @@ def test_product_portfolio_detail_view_tab_vulnerability_queryset(self):
self.assertEqual(4, product1.packages.vulnerable().count())
url = product1.get_url("tab_vulnerabilities")
- with self.assertMaxQueries(12):
+ with self.assertMaxQueries(16):
response = self.client.get(url)
self.assertContains(response, "4 results")
def test_product_portfolio_tab_vulnerability_view_filters(self):
self.client.login(username="nexb_user", password="secret")
+ # The "Recommendation" column, and its triage_action filter, only render when the
+ # product has at least one enabled TriageRuleset assigned to it.
+ ruleset = TriageRuleset.objects.create(
+ name="Upgrade Ruleset",
+ recommended_action=TriageAction.UPGRADE,
+ precedence=100,
+ dataspace=self.dataspace,
+ )
+ ProductTriageRuleset.objects.create(
+ product=self.product1, ruleset=ruleset, dataspace=self.dataspace
+ )
+
url = self.product1.get_url("tab_vulnerabilities")
response = self.client.get(url)
- self.assertContains(response, "?vulnerabilities-weighted_risk_score=#vulnerabilities")
- self.assertContains(response, "?vulnerabilities-sort=weighted_risk_score#vulnerabilities")
- response = self.client.get(
- url + "?vulnerabilities-sort=weighted_risk_score#vulnerabilities"
+ self.assertContains(response, "?vulnerabilities-triage_action=#vulnerabilities")
+ self.assertContains(response, "?vulnerabilities-triage_action=upgrade#vulnerabilities")
+ self.assertContains(
+ response, "?vulnerabilities-vulnerability_analyses__state=#vulnerabilities"
)
- self.assertContains(response, "?vulnerabilities-sort=-weighted_risk_score#vulnerabilities")
def test_product_portfolio_tab_vulnerability_view_packages_row_rendering(self):
self.client.login(username="nexb_user", password="secret")
@@ -320,11 +335,9 @@ def test_product_portfolio_tab_vulnerability_view_packages_row_rendering(self):
url = product1.get_url("tab_vulnerabilities")
response = self.client.get(url)
expected = f"""
-
-
- {p1}
-
-
+
+ {p1}
+
"""
self.assertContains(response, expected, html=True)
@@ -335,8 +348,8 @@ def test_product_portfolio_tab_vulnerability_view_packages_row_rendering(self):
data-package-identifier="{p1}"
data-edit-url="/products/vulnerability_analysis/{pp1.uuid}/{vulnerability1.advisory_uid}/"
>
-
+
@@ -360,7 +373,7 @@ def test_product_portfolio_tab_vulnerability_view_queries(self):
make_vulnerability_analysis(product_package2, vulnerability2)
url = product1.get_url("tab_vulnerabilities")
- with self.assertMaxQueries(12):
+ with self.assertMaxQueries(15):
self.client.get(url)
def test_product_portfolio_tab_vulnerability_risk_threshold(self):
@@ -405,19 +418,24 @@ def test_product_portfolio_tab_vulnerability_view_analysis_rendering(self):
response = self.client.get(url)
expected = """
-
- Resolved
-
-
-
-
- Code Not Present
-
-
- can_not_fix
- rollback
-
+
+
+
+
+ Resolved
+
+
Code Not Present
+
+ detail
+
+
+ Can Not Fix
+ Rollback
+
+
+
"""
self.assertContains(response, expected, html=True)
@@ -1982,6 +2000,12 @@ def test_product_portfolio_product_update_save_as_new(self):
CodebaseResource.objects.create(
path="/path1/", product=self.product1, dataspace=self.dataspace
)
+ ruleset = TriageRuleset.objects.create(
+ name="Upgrade Ruleset", precedence=100, dataspace=self.dataspace
+ )
+ ProductTriageRuleset.objects.create(
+ product=self.product1, ruleset=ruleset, dataspace=self.dataspace
+ )
initial_product_count = Product.objects.get_queryset(self.super_user).count()
data = {
@@ -2003,6 +2027,7 @@ def test_product_portfolio_product_update_save_as_new(self):
self.assertEqual(1, cloned_product.productcomponents.count())
self.assertEqual(1, cloned_product.productpackages.count())
self.assertEqual(1, cloned_product.codebaseresources.count())
+ self.assertEqual(1, cloned_product.product_triage_rulesets.count())
def test_product_portfolio_product_delete_view(self):
delete_url = self.product1.get_delete_url()
@@ -3411,6 +3436,54 @@ def test_product_portfolio_vulnerability_analysis_form_view(self):
self.assertEqual(vulnerability1, analysis.vulnerability)
self.assertEqual("resolved", analysis.state)
+ def test_product_portfolio_apply_analysis_preset_view(self):
+ self.client.login(username=self.super_user.username, password="secret")
+
+ package1 = make_package(self.dataspace)
+ vulnerability1 = make_vulnerability(self.dataspace, affecting=[package1])
+ product1 = make_product(self.dataspace, inventory=[package1])
+ product_package = ProductPackage.objects.get(product=product1, package=package1)
+ preset = AnalysisPreset.objects.create(
+ name="Preset1",
+ state="not_affected",
+ detail="Not deployed",
+ dataspace=self.dataspace,
+ )
+
+ url = reverse(
+ "product_portfolio:apply_analysis_preset",
+ args=[product_package.uuid, preset.pk, vulnerability1.advisory_uid],
+ )
+ response = self.client.post(url)
+ self.assertEqual(200, response.status_code)
+ self.assertEqual(b'{"success": "applied"}', response.content)
+ analysis = VulnerabilityAnalysis.objects.get()
+ self.assertEqual(product_package, analysis.product_package)
+ self.assertEqual(vulnerability1, analysis.vulnerability)
+ self.assertEqual("not_affected", analysis.state)
+ self.assertEqual(preset, analysis.applied_by_preset)
+
+ def test_product_portfolio_apply_analysis_preset_view_existing_analysis(self):
+ self.client.login(username=self.super_user.username, password="secret")
+
+ package1 = make_package(self.dataspace)
+ vulnerability1 = make_vulnerability(self.dataspace, affecting=[package1])
+ product1 = make_product(self.dataspace, inventory=[package1])
+ product_package = ProductPackage.objects.get(product=product1, package=package1)
+ make_vulnerability_analysis(product_package, vulnerability1, state="exploitable")
+ preset = AnalysisPreset.objects.create(
+ name="Preset1", state="not_affected", dataspace=self.dataspace
+ )
+
+ url = reverse(
+ "product_portfolio:apply_analysis_preset",
+ args=[product_package.uuid, preset.pk, vulnerability1.advisory_uid],
+ )
+ response = self.client.post(url)
+ self.assertEqual(400, response.status_code)
+ self.assertEqual(1, VulnerabilityAnalysis.objects.count())
+ self.assertEqual("exploitable", VulnerabilityAnalysis.objects.get().state)
+
def test_product_portfolio_tab_compliance_view_empty(self):
self.client.login(username="nexb_user", password="secret")
url = self.product1.get_url("tab_compliance")
@@ -4283,6 +4356,96 @@ def test_post_with_change_perm_evaluates_and_returns_hx_refresh(self, mock_evalu
mock_evaluate.assert_called_once_with(self.product1)
+class ManageTriageRulesetsViewTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.super_user = create_superuser("nexb_user", self.dataspace)
+ self.basic_user = create_user("basic_user", self.dataspace)
+ self.product1 = Product.objects.create(
+ name="Product1", version="1.0", dataspace=self.dataspace
+ )
+ self.ruleset = TriageRuleset.objects.create(
+ name="Upgrade Ruleset",
+ recommended_action=TriageAction.UPGRADE,
+ precedence=100,
+ dataspace=self.dataspace,
+ )
+
+ def test_get_without_login_redirects(self):
+ url = self.product1.get_manage_triage_rulesets_url()
+ response = self.client.get(url)
+ self.assertEqual(302, response.status_code)
+
+ def test_get_without_change_perm_returns_404(self):
+ self.client.login(username="basic_user", password="secret")
+ url = self.product1.get_manage_triage_rulesets_url()
+ response = self.client.get(url)
+ self.assertEqual(404, response.status_code)
+
+ def test_get_with_change_perm_renders_available_rulesets(self):
+ self.client.login(username="nexb_user", password="secret")
+ url = self.product1.get_manage_triage_rulesets_url()
+ response = self.client.get(url)
+ self.assertEqual(200, response.status_code)
+ self.assertContains(response, self.ruleset.name)
+ self.assertNotContains(response, "checked")
+
+ def test_get_displays_the_ruleset_analysis_preset(self):
+ preset = AnalysisPreset.objects.create(
+ name="Auto-Close Preset", state="not_affected", dataspace=self.dataspace
+ )
+ self.ruleset.analysis_preset = preset
+ self.ruleset.save()
+ self.client.login(username="nexb_user", password="secret")
+ url = self.product1.get_manage_triage_rulesets_url()
+ response = self.client.get(url)
+ self.assertContains(response, preset.name)
+
+ def test_get_marks_the_assigned_rulesets_as_checked(self):
+ ProductTriageRuleset.objects.create(
+ product=self.product1, ruleset=self.ruleset, dataspace=self.dataspace
+ )
+ self.client.login(username="nexb_user", password="secret")
+ url = self.product1.get_manage_triage_rulesets_url()
+ response = self.client.get(url)
+ self.assertContains(response, "checked")
+
+ def test_post_without_change_perm_returns_404(self):
+ self.client.login(username="basic_user", password="secret")
+ url = self.product1.get_manage_triage_rulesets_url()
+ response = self.client.post(url)
+ self.assertEqual(404, response.status_code)
+
+ @patch("product_portfolio.views.evaluate_ruleset")
+ def test_post_assigns_the_submitted_rulesets(self, mock_evaluate):
+ self.client.login(username="nexb_user", password="secret")
+ url = self.product1.get_manage_triage_rulesets_url()
+ response = self.client.post(url, {"ruleset_uuids": [str(self.ruleset.uuid)]})
+ self.assertEqual(b'{"success": true}', response.content)
+ self.assertTrue(
+ ProductTriageRuleset.objects.filter(
+ product=self.product1, ruleset=self.ruleset
+ ).exists()
+ )
+ mock_evaluate.assert_called_once_with(ruleset=self.ruleset, product=self.product1)
+
+ @patch("product_portfolio.views.evaluate_ruleset")
+ def test_post_unassigns_the_deselected_rulesets(self, mock_evaluate):
+ ProductTriageRuleset.objects.create(
+ product=self.product1, ruleset=self.ruleset, dataspace=self.dataspace
+ )
+ self.client.login(username="nexb_user", password="secret")
+ url = self.product1.get_manage_triage_rulesets_url()
+ response = self.client.post(url, {"ruleset_uuids": []})
+ self.assertEqual(b'{"success": true}', response.content)
+ self.assertFalse(
+ ProductTriageRuleset.objects.filter(
+ product=self.product1, ruleset=self.ruleset
+ ).exists()
+ )
+ mock_evaluate.assert_not_called()
+
+
class TabCompliancePolicyContextTestCase(TestCase):
def setUp(self):
self.dataspace = Dataspace.objects.create(name="nexB")
diff --git a/product_portfolio/urls.py b/product_portfolio/urls.py
index 8231594a..7cfa0675 100644
--- a/product_portfolio/urls.py
+++ b/product_portfolio/urls.py
@@ -39,6 +39,7 @@
from product_portfolio.views import ProductUpdateView
from product_portfolio.views import PullProjectDataFromScanCodeIOView
from product_portfolio.views import add_customcomponent_ajax_view
+from product_portfolio.views import apply_analysis_preset_view
from product_portfolio.views import check_package_version_ajax_view
from product_portfolio.views import delete_scan_htmx_view
from product_portfolio.views import edit_productrelation_ajax_view
@@ -46,6 +47,7 @@
from product_portfolio.views import import_from_scan_view
from product_portfolio.views import import_packages_from_scancodeio_view
from product_portfolio.views import improve_packages_from_purldb_view
+from product_portfolio.views import manage_triage_rulesets_view
from product_portfolio.views import scan_all_packages_view
from product_portfolio.views import scancodeio_project_download_input_view
from product_portfolio.views import scancodeio_project_status_view
@@ -117,6 +119,11 @@ def product_path(path_segment, view):
edit_productrelation_ajax_view,
name="edit_productrelation_ajax",
),
+ path(
+ "vulnerability_analysis//preset///",
+ apply_analysis_preset_view,
+ name="apply_analysis_preset",
+ ),
path(
"vulnerability_analysis///",
vulnerability_analysis_form_view,
@@ -131,6 +138,7 @@ def product_path(path_segment, view):
*product_path("vulnerability_analysis_form", vulnerability_analysis_form_view),
*product_path("scan_all_packages", scan_all_packages_view),
*product_path("evaluate_policy_rules", evaluate_policy_rules_view),
+ *product_path("manage_triage_rulesets", manage_triage_rulesets_view),
*product_path("improve_packages_from_purldb", improve_packages_from_purldb_view),
*product_path("about_files", ProductSendAboutFilesView.as_view()),
*product_path("export_spdx", ProductExportSPDXDocumentView.as_view()),
diff --git a/product_portfolio/views.py b/product_portfolio/views.py
index baf3c463..64a59492 100644
--- a/product_portfolio/views.py
+++ b/product_portfolio/views.py
@@ -150,6 +150,39 @@
from vulnerabilities.models import Vulnerability
from vulnerabilities.models import VulnerabilityAnalysis
from vulnerabilities.models import get_risk_level
+from vulnerabilities.triage.engine import evaluate_ruleset
+from vulnerabilities.triage.models import AnalysisPreset
+from vulnerabilities.triage.models import ProductTriageRuleset
+from vulnerabilities.triage.models import TriageAction
+from vulnerabilities.triage.models import TriageRecord
+from vulnerabilities.triage.models import TriageRuleset
+from vulnerabilities.triage.rules import RULE_REGISTRY as TRIAGE_RULE_REGISTRY
+from vulnerabilities.triage.rules import rule_parameters_from_config
+
+TRIAGE_ACTION_STYLES = {
+ "upgrade": ("bg-danger-subtle text-danger-emphasis", "fa-arrow-circle-up"),
+ "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",
+)
+
+ANALYSIS_STATE_STYLES = {
+ "exploitable": "bg-danger-subtle text-danger-emphasis",
+ "in_triage": "bg-warning-subtle text-warning-emphasis",
+ "resolved": "bg-success-subtle text-success-emphasis",
+ "resolved_with_pedigree": "bg-success-subtle text-success-emphasis",
+ "not_affected": "bg-secondary-subtle text-secondary-emphasis",
+ "false_positive": "bg-secondary-subtle text-secondary-emphasis",
+}
+ANALYSIS_STATE_DEFAULT_STYLE = "bg-secondary-subtle text-secondary-emphasis"
class BaseProductViewMixin:
@@ -1196,6 +1229,10 @@ def get_context_data(self, **kwargs):
return context_data
+def has_triage_column_condition(view):
+ return getattr(view, "has_triage_rulesets", True)
+
+
class ProductTabVulnerabilitiesView(
LoginRequiredMixin,
BaseProductViewMixin,
@@ -1211,44 +1248,134 @@ 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(
"advisory_uid",
_("Vulnerabilities"),
help_text="Vulnerabilities affecting the product package",
),
Header(
- "vulnerability_analyses__state",
- _("Status"),
- help_text=_("Exploitability analysis status"),
- filter="vulnerability_analyses__state",
- ),
- Header(
- "vulnerability_analyses__justification",
- _("Justification"),
- help_text=_("The rationale of why the impact analysis state was asserted."),
- filter="vulnerability_analyses__justification",
- ),
- Header(
- "vulnerability_analyses__responses",
- _("Responses"),
- help_text=_(
- "A response to the vulnerability by the manufacturer, supplier, or project "
- "responsible for the affected component or service."
- ),
- filter="responses",
+ "triage_action",
+ _("Recommendation"),
+ help_text=_("Action recommended by the triage engine for this vulnerability"),
+ filter="triage_action",
+ condition=has_triage_column_condition,
),
Header(
- "vulnerability_analyses__is_reachable",
- _("Reach"),
+ "vulnerability_analyses__state",
+ _("Analysis"),
help_text=_(
- "Indicates whether the vulnerability is reachable in the context of "
- "this product package."
+ "Exploitability analysis: status, justification, responses and reachability."
),
- filter="is_reachable",
+ filter="vulnerability_analyses__state",
),
)
+ def attach_vulnerability_analyses(self, page_obj):
+ """Set the matching VulnerabilityAnalysis instance on each prefetched vulnerability."""
+ response_labels = dict(VulnerabilityAnalysis.Response.choices)
+
+ for product_package in page_obj.object_list:
+ for vulnerability in product_package.package.affected_by_vulnerabilities.all():
+ for analysis in vulnerability.vulnerability_analyses.all():
+ if analysis.product_package_id == product_package.id:
+ vulnerability.vulnerability_analysis = analysis
+ analysis.state_badge_class = ANALYSIS_STATE_STYLES.get(
+ analysis.state, ANALYSIS_STATE_DEFAULT_STYLE
+ )
+ analysis.response_labels = [
+ response_labels.get(response, response)
+ for response in analysis.responses or []
+ ]
+ break
+
+ REACHABILITY_FILTER_MAP = {"yes": True, "no": False, "unknown": None}
+
+ def get_vulnerability_display_filters(self):
+ """Return the active per-vulnerability filters from the request."""
+ params = self.request.GET
+ prefix = self.tab_id
+ return {
+ "triage_action": params.get(f"{prefix}-triage_action", ""),
+ "state": params.get(f"{prefix}-vulnerability_analyses__state", ""),
+ "justification": params.get(f"{prefix}-vulnerability_analyses__justification", ""),
+ "is_reachable": params.get(f"{prefix}-is_reachable", ""),
+ }
+
+ def vulnerability_passes_display_filters(self, vulnerability, display_filters):
+ """Return True if the vulnerability matches all active display filters."""
+ triage_action = display_filters.get("triage_action")
+ if triage_action:
+ record = getattr(vulnerability, "triage_record", None)
+ if getattr(record, "recommended_action", "") != triage_action:
+ return False
+
+ analysis = getattr(vulnerability, "vulnerability_analysis", None)
+ state = display_filters.get("state")
+ if state:
+ if getattr(analysis, "state", "") != state:
+ return False
+
+ justification = display_filters.get("justification")
+ if justification:
+ if getattr(analysis, "justification", "") != justification:
+ return False
+
+ is_reachable_filter = display_filters.get("is_reachable")
+ if is_reachable_filter in self.REACHABILITY_FILTER_MAP:
+ expected = self.REACHABILITY_FILTER_MAP[is_reachable_filter]
+ actual = None if analysis is None else analysis.is_reachable
+ if actual != expected:
+ return False
+
+ return True
+
+ def attach_triage_data(self, product, page_obj):
+ """
+ Attach the winning TriageRecord to each vulnerability and build
+ display_vulnerabilities on each product_package, filtered by any
+ active per-vulnerability filters.
+ """
+ vulnerability_ids = {
+ vulnerability.id
+ for product_package in page_obj.object_list
+ for vulnerability in product_package.package.affected_by_vulnerabilities.all()
+ }
+ action_labels = dict(TriageAction.choices)
+ triage_records = list(
+ TriageRecord.objects.filter(
+ product=product,
+ vulnerability_id__in=vulnerability_ids,
+ )
+ .highest_precedence()
+ .select_related("ruleset", "request")
+ )
+ for record in triage_records:
+ record.action_label = action_labels.get(
+ record.recommended_action, record.recommended_action
+ )
+ badge_class, icon = TRIAGE_ACTION_STYLES.get(
+ record.recommended_action, TRIAGE_ACTION_DEFAULT_STYLE
+ )
+ record.action_badge_class = badge_class
+ record.action_icon = icon
+
+ triage_by_vulnerability = {record.vulnerability_id: record for record in triage_records}
+ display_filters = self.get_vulnerability_display_filters()
+ has_display_filters = any(display_filters.values())
+
+ for product_package in page_obj.object_list:
+ all_vulnerabilities = list(product_package.package.affected_by_vulnerabilities.all())
+ for vulnerability in all_vulnerabilities:
+ vulnerability.triage_record = triage_by_vulnerability.get(vulnerability.id)
+ if has_display_filters:
+ product_package.display_vulnerabilities = [
+ vulnerability
+ for vulnerability in all_vulnerabilities
+ if self.vulnerability_passes_display_filters(vulnerability, display_filters)
+ ]
+ else:
+ product_package.display_vulnerabilities = all_vulnerabilities
+
def get_context_data(self, **kwargs):
product = self.object
@@ -1258,9 +1385,12 @@ def get_context_data(self, **kwargs):
risk_threshold = product.get_vulnerabilities_risk_threshold()
base_productpackage_qs = product.get_vulnerable_productpackages(risk_threshold)
+ vulnerability_analyses_qs = VulnerabilityAnalysis.objects.select_related(
+ "created_by", "last_modified_by", "applied_by_preset"
+ )
vulnerability_qs = Vulnerability.objects.prefetch_related(
- "vulnerability_analyses"
- ).order_by("-risk_score")
+ Prefetch("vulnerability_analyses", queryset=vulnerability_analyses_qs)
+ ).order_by(F("risk_score").desc(nulls_last=True))
package_qs = (
Package.objects.all()
.only_rendering_fields()
@@ -1278,7 +1408,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",
)
)
@@ -1291,6 +1421,10 @@ def get_context_data(self, **kwargs):
anchor=f"#{self.tab_id}",
)
+ self.has_triage_rulesets = product.product_triage_rulesets.filter(
+ ruleset__enabled=True
+ ).exists()
+
# The self.filterset needs to be set before calling super()
context_data = super().get_context_data(**kwargs)
@@ -1298,14 +1432,19 @@ def get_context_data(self, **kwargs):
page_number = self.request.GET.get(self.query_dict_page_param)
page_obj = paginator.get_page(page_number)
- # Set the proper VulnerabilityAnalysis instance on the Package instance
- for product_package in page_obj.object_list:
- for vulnerability in product_package.package.affected_by_vulnerabilities.all():
- for analysis in vulnerability.vulnerability_analyses.all():
- if analysis.product_package_id == product_package.id:
- vulnerability.vulnerability_analysis = analysis
- continue
+ self.attach_vulnerability_analyses(page_obj)
+ self.attach_triage_data(product, page_obj)
+ analysis_presets = list(AnalysisPreset.objects.scope(product.dataspace))
+ has_change_permission = "change_product" in guardian_get_perms(self.request.user, product)
+ can_manage_triage_rules = (
+ has_change_permission and self.request.user.dataspace.enable_vulnerablecodedb_access
+ )
+ manage_triage_rules_nav_item_template = None
+ if can_manage_triage_rules:
+ manage_triage_rules_nav_item_template = (
+ "product_portfolio/includes/manage_triage_rules_nav_item.html"
+ )
context_data.update(
{
"filterset": self.filterset,
@@ -1313,6 +1452,9 @@ def get_context_data(self, **kwargs):
"total_count": base_productpackage_qs.count(),
"search_query": self.request.GET.get("vulnerabilities-q", ""),
"risk_threshold": risk_threshold,
+ "has_triage_rulesets": self.has_triage_rulesets,
+ "analysis_presets": analysis_presets,
+ "manage_triage_rules_nav_item_template": manage_triage_rules_nav_item_template,
}
)
@@ -2084,6 +2226,79 @@ def evaluate_policy_rules_view(request, dataspace, name, version=""):
return HttpResponse(headers={"HX-Refresh": "true"})
+@require_http_methods(["GET", "POST"])
+@login_required
+def manage_triage_rulesets_view(request, dataspace, name, version=""):
+ guarded_qs = Product.objects.get_queryset(request.user, perms="change_product")
+ product = get_object_or_404(
+ guarded_qs,
+ name=unquote_plus(name),
+ version=unquote_plus(version),
+ dataspace__name=dataspace,
+ )
+ available_rulesets = list(
+ TriageRuleset.objects.filter(dataspace=product.dataspace, enabled=True)
+ .select_related("analysis_preset")
+ .order_by("-precedence", "name")
+ )
+
+ if request.method == "POST":
+ submitted_uuids = set(request.POST.getlist("ruleset_uuids"))
+ current_assignments = {
+ str(ptr.ruleset.uuid): ptr
+ for ptr in ProductTriageRuleset.objects.filter(
+ product=product, ruleset__enabled=True
+ ).select_related("ruleset")
+ }
+ for ruleset in available_rulesets:
+ ruleset_uuid = str(ruleset.uuid)
+ if ruleset_uuid in submitted_uuids and ruleset_uuid not in current_assignments:
+ with transaction.atomic():
+ ProductTriageRuleset.objects.create(
+ product=product,
+ ruleset=ruleset,
+ dataspace=product.dataspace,
+ )
+ evaluate_ruleset(ruleset=ruleset, product=product)
+ for ruleset_uuid, assignment in current_assignments.items():
+ if ruleset_uuid not in submitted_uuids:
+ assignment.delete()
+ return JsonResponse({"success": True})
+
+ assigned_ruleset_ids = set(product.product_triage_rulesets.values_list("ruleset_id", flat=True))
+ action_labels = dict(TriageAction.choices)
+
+ for ruleset in available_rulesets:
+ ruleset.action_label = action_labels.get(
+ ruleset.recommended_action, ruleset.recommended_action
+ )
+ action_badge_class, action_icon = TRIAGE_ACTION_STYLES.get(
+ ruleset.recommended_action, TRIAGE_ACTION_DEFAULT_STYLE
+ )
+ ruleset.action_badge_class = action_badge_class
+ ruleset.action_icon = action_icon
+ active_rules = []
+ for rule_type, config in ruleset.rules_config.items():
+ if rule_type not in TRIAGE_RULE_REGISTRY or not config.get("is_active"):
+ continue
+ handler = TRIAGE_RULE_REGISTRY[rule_type]
+ params = rule_parameters_from_config(config)
+ params_str = ", ".join(
+ f"{key.replace('_', ' ')}: {value}" for key, value in params.items()
+ )
+ active_rules.append({"label": handler.label, "params_str": params_str})
+ ruleset.active_rules = active_rules
+
+ return render(
+ request,
+ "product_portfolio/modals/manage_triage_rulesets_form.html",
+ {
+ "available_rulesets": available_rulesets,
+ "assigned_ruleset_ids": assigned_ruleset_ids,
+ },
+ )
+
+
@login_required
def import_from_scan_view(request, dataspace, name, version=""):
"""
@@ -2742,6 +2957,44 @@ def vulnerability_analysis_form_view(request, productpackage_uuid, advisory_uid)
return HttpResponse(rendered_form)
+@login_required
+@require_POST
+def apply_analysis_preset_view(request, productpackage_uuid, advisory_uid, preset_id):
+ user = request.user
+ dataspace = user.dataspace
+
+ product_package_qs = ProductPackage.objects.product_secured(user, perms="change_product")
+ product_package = get_object_or_404(product_package_qs, uuid=productpackage_uuid)
+ vulnerability = get_object_or_404(
+ Vulnerability.objects.scope(dataspace), advisory_uid=advisory_uid
+ )
+ preset = get_object_or_404(AnalysisPreset.objects.scope(dataspace), pk=preset_id)
+
+ existing = VulnerabilityAnalysis.objects.scope(dataspace).get_or_none(
+ product_package=product_package,
+ vulnerability=vulnerability,
+ )
+ if existing:
+ return JsonResponse(
+ {"error": "An analysis already exists for this vulnerability."}, status=400
+ )
+
+ analysis = VulnerabilityAnalysis(
+ product_package=product_package,
+ vulnerability=vulnerability,
+ dataspace=dataspace,
+ )
+ preset.apply_to_analysis(analysis)
+
+ if not analysis.has_content_fields():
+ return JsonResponse({"error": "This preset has no content fields to apply."}, status=400)
+
+ analysis.applied_by_preset = preset
+ analysis.save()
+
+ return JsonResponse({"success": "applied"}, status=200)
+
+
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
diff --git a/vulnerabilities/forms.py b/vulnerabilities/forms.py
index 3d87d3ba..22b27e92 100644
--- a/vulnerabilities/forms.py
+++ b/vulnerabilities/forms.py
@@ -97,6 +97,7 @@ def helper(self):
return helper
def save(self, *args, **kwargs):
+ self.instance.applied_by_preset = None
instance = super().save(*args, **kwargs)
if products := self.cleaned_data.get("propagate_to_products"):
diff --git a/vulnerabilities/migrations/0011_vulnerabilityanalysis_applied_by_preset.py b/vulnerabilities/migrations/0011_vulnerabilityanalysis_applied_by_preset.py
new file mode 100644
index 00000000..8d4c3323
--- /dev/null
+++ b/vulnerabilities/migrations/0011_vulnerabilityanalysis_applied_by_preset.py
@@ -0,0 +1,20 @@
+# Generated by Django 6.0.6 on 2026-08-14 15:02
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('vulnerabilities', '0010_api_v3_remove_old_fields'),
+ ('vulnerabilities_triage', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vulnerabilityanalysis',
+ name='applied_by_preset',
+ field=models.ForeignKey(blank=True, help_text='The analysis preset that automatically created this analysis. Cleared when a user edits the analysis manually.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applied_analyses', to='vulnerabilities_triage.analysispreset'),
+ ),
+ ]
diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py
index b0d25a84..8a3d89d9 100644
--- a/vulnerabilities/models.py
+++ b/vulnerabilities/models.py
@@ -274,8 +274,8 @@ def as_cyclonedx(self, affected_instances, analysis=None):
)
-class VulnerabilityAnalysisMixin(models.Model):
- """Aligned with the cyclonedx.model.vulnerability.VulnerabilityAnalysis"""
+class VulnerabilityAnalysisContentMixin(models.Model):
+ """Core analysis content fields, shared with AnalysisPreset. CycloneDX-aligned."""
# cyclonedx.model.impact_analysis.ImpactAnalysisState
class State(models.TextChoices):
@@ -344,6 +344,26 @@ class Response(models.TextChoices):
"details on why the component or service is not impacted by this vulnerability."
),
)
+
+ def has_content_fields(self):
+ return any([self.state, self.justification, self.responses, self.detail])
+
+ def save(self, *args, **kwargs):
+ # At least one of those fields must be provided.
+ if not self.has_content_fields():
+ raise ValueError(
+ "At least one of state, justification, responses or detail must be provided."
+ )
+
+ super().save(*args, **kwargs)
+
+ class Meta:
+ abstract = True
+
+
+class VulnerabilityAnalysisMixin(VulnerabilityAnalysisContentMixin):
+ """Aligned with the cyclonedx.model.vulnerability.VulnerabilityAnalysis"""
+
first_issued = models.DateTimeField(
auto_now_add=True,
help_text=_("The date and time (timestamp) when the analysis was first issued."),
@@ -356,21 +376,6 @@ class Response(models.TextChoices):
class Meta:
abstract = True
- def save(self, *args, **kwargs):
- # At least one of those fields must be provided.
- main_fields = [
- self.state,
- self.justification,
- self.responses,
- self.detail,
- ]
- if not any(main_fields):
- raise ValueError(
- "At least one of state, justification, responses or detail must be provided."
- )
-
- super().save(*args, **kwargs)
-
def as_cyclonedx(self):
state = None
if self.state:
@@ -546,6 +551,17 @@ class VulnerabilityAnalysis(
"product package."
),
)
+ applied_by_preset = models.ForeignKey(
+ to="vulnerabilities_triage.AnalysisPreset",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="applied_analyses",
+ help_text=_(
+ "The analysis preset that automatically created this analysis."
+ " Cleared when a user edits the analysis manually."
+ ),
+ )
objects = DataspacedManager.from_queryset(ProductSecuredQuerySet)()
diff --git a/vulnerabilities/tests/test_forms.py b/vulnerabilities/tests/test_forms.py
index 31bd71d6..e964a4e3 100644
--- a/vulnerabilities/tests/test_forms.py
+++ b/vulnerabilities/tests/test_forms.py
@@ -16,6 +16,7 @@
from product_portfolio.tests import make_product_package
from vulnerabilities.forms import VulnerabilityAnalysisForm
from vulnerabilities.tests import make_vulnerability
+from vulnerabilities.triage.models import AnalysisPreset
class VulnerabilitiesFormsTestCase(TestCase):
@@ -49,6 +50,31 @@ def test_vulnerability_forms_vulnerability_analysis_save(self):
self.assertEqual(product_package1.package, analysis.package)
self.assertEqual(data["detail"], analysis.detail)
+ def test_vulnerability_forms_vulnerability_analysis_save_clears_applied_by_preset(self):
+ product_package1 = make_product_package(make_product(self.dataspace))
+ vulnerability1 = make_vulnerability(
+ dataspace=self.dataspace, affecting=[product_package1.package]
+ )
+ preset = AnalysisPreset.objects.create(
+ name="Preset1", state="not_affected", dataspace=self.dataspace
+ )
+ analysis = product_package1.vulnerability_analyses.create(
+ vulnerability=vulnerability1,
+ dataspace=self.dataspace,
+ state="not_affected",
+ applied_by_preset=preset,
+ )
+
+ data = {
+ "product_package": product_package1,
+ "vulnerability": vulnerability1,
+ "detail": "Human edit",
+ }
+ form = VulnerabilityAnalysisForm(user=self.super_user, data=data, instance=analysis)
+ self.assertTrue(form.is_valid())
+ saved_analysis = form.save()
+ self.assertIsNone(saved_analysis.applied_by_preset)
+
def test_vulnerability_forms_vulnerability_analysis_propagate_to_products(self):
product_package1 = make_product_package(make_product(self.dataspace))
vulnerability1 = make_vulnerability(
diff --git a/vulnerabilities/triage/__init__.py b/vulnerabilities/triage/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py
new file mode 100644
index 00000000..57e1a714
--- /dev/null
+++ b/vulnerabilities/triage/admin.py
@@ -0,0 +1,179 @@
+#
+# 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 django.utils.html import escape
+from django.utils.html import mark_safe
+
+from dje.admin import DataspacedAdmin
+from dje.admin import dejacode_site
+from dje.list_display import AsLink
+from vulnerabilities.triage.forms import AnalysisPresetForm
+from vulnerabilities.triage.forms import TriageRulesetForm
+from vulnerabilities.triage.models import AnalysisPreset
+from vulnerabilities.triage.models import TriageAction
+from vulnerabilities.triage.models import TriageRuleset
+from vulnerabilities.triage.rules import RULE_REGISTRY
+from vulnerabilities.triage.rules import rule_parameters_from_config
+
+
+@admin.register(AnalysisPreset, site=dejacode_site)
+class AnalysisPresetAdmin(DataspacedAdmin):
+ short_description = (
+ "An Analysis Preset defines default vulnerability analysis values that the triage"
+ " engine applies automatically when an assigned ruleset fires."
+ )
+ long_description = (
+ "Only non-blank preset fields are applied. Analyses already modified by a human"
+ " are never overwritten. When a user edits an auto-applied analysis, the preset"
+ " link is cleared and the analysis becomes human-owned."
+ )
+ form = AnalysisPresetForm
+ list_display = [
+ "name",
+ "state",
+ "justification",
+ "get_responses",
+ "is_reachable",
+ "description",
+ "get_dataspace",
+ ]
+ search_fields = ["name"]
+ fieldsets = [
+ (
+ None,
+ {"fields": ["name", "description"]},
+ ),
+ (
+ "Analysis defaults",
+ {
+ "fields": ["state", "justification", "responses", "detail", "is_reachable"],
+ "description": (
+ "Leave a field blank to leave it unchanged on the analysis."
+ " At least one field should be set."
+ ),
+ },
+ ),
+ ]
+
+ @admin.display(description="Responses")
+ def get_responses(self, obj):
+ if not obj.responses:
+ return ""
+
+ labels = dict(AnalysisPreset.Response.choices)
+ return ", ".join(labels.get(response, response) for response in obj.responses)
+
+
+@admin.register(TriageRuleset, site=dejacode_site)
+class TriageRulesetAdmin(DataspacedAdmin):
+ short_description = (
+ "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_action_label",
+ "precedence",
+ "get_enabled_rules",
+ AsLink("analysis_preset"),
+ AsLink("request_template"),
+ "description",
+ "enabled",
+ "get_dataspace",
+ ]
+ 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.recommended_action, obj.recommended_action)
+
+ @admin.display(description="Enabled rules")
+ def get_enabled_rules(self, obj):
+ 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 = rule_parameters_from_config(config)
+ if params:
+ param_str = ", ".join(f"{key}: {value}" for key, value in params.items())
+ label = f"{handler.label} ({param_str})"
+ else:
+ label = handler.label
+ lines.append(escape(label))
+
+ if not lines:
+ return ""
+
+ return mark_safe(" ".join(lines))
+
+ def get_changes_details(self, form):
+ model_field_names = {field.name for field in TriageRuleset._meta.get_fields()}
+ form.__dict__["changed_data"] = [
+ field_name for field_name in form.changed_data if field_name in model_field_names
+ ]
+ return super().get_changes_details(form)
+
+ def get_form(self, request, obj=None, change=False, **kwargs):
+ kwargs["fields"] = [
+ "name",
+ "description",
+ "recommended_action",
+ "precedence",
+ "enabled",
+ "analysis_preset",
+ "request_template",
+ ]
+ return super().get_form(request, obj, change=change, **kwargs)
+
+ def get_fieldsets(self, request, obj=None):
+ base_fieldsets = [
+ (
+ None,
+ {
+ "fields": [
+ "name",
+ "description",
+ "recommended_action",
+ "precedence",
+ "enabled",
+ "analysis_preset",
+ "request_template",
+ ]
+ },
+ ),
+ ]
+
+ 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",),
+ },
+ )
+ )
+
+ return base_fieldsets + rule_fieldsets
diff --git a/vulnerabilities/triage/apps.py b/vulnerabilities/triage/apps.py
new file mode 100644
index 00000000..9f2406cb
--- /dev/null
+++ b/vulnerabilities/triage/apps.py
@@ -0,0 +1,19 @@
+#
+# 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
+from django.utils.translation import gettext_lazy as _
+
+
+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/engine.py b/vulnerabilities/triage/engine.py
new file mode 100644
index 00000000..a896d985
--- /dev/null
+++ b/vulnerabilities/triage/engine.py
@@ -0,0 +1,227 @@
+#
+# 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.utils import timezone
+
+from vulnerabilities.triage.models import TriageRecord
+from vulnerabilities.triage.rules import RULE_REGISTRY
+from vulnerabilities.triage.rules import rule_parameters_from_config
+
+
+def collect_matches(ruleset, product):
+ """
+ Return a dict mapping each matching Vulnerability PK to the list of rule
+ types that fired for it, for all active rules in the ruleset.
+ """
+ matched_rules_per_vulnerability_id = {}
+
+ 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
+
+ parameters = rule_parameters_from_config(config)
+ matching_vulnerability_ids = handler.get_matching_vulnerabilities(
+ product=product,
+ parameters=parameters,
+ ).values_list("pk", flat=True)
+
+ for vulnerability_id in matching_vulnerability_ids:
+ matched_rules_per_vulnerability_id.setdefault(vulnerability_id, []).append(rule_type)
+
+ return matched_rules_per_vulnerability_id
+
+
+def apply_preset_for_vulnerabilities(preset, product, vulnerability_ids):
+ """
+ For each (product_package, vulnerability) pair in the product, create or update
+ a VulnerabilityAnalysis using preset values.
+
+ Skips any analysis already modified by a human (applied_by_preset is null on an
+ existing record). Only analyses that were auto-created (applied_by_preset is set)
+ or brand-new are touched. Skips creating a new analysis when the preset has no
+ content fields set (state/justification/responses/detail), since saving an
+ analysis with only is_reachable would fail model validation.
+ """
+ VulnerabilityAnalysis = apps.get_model("vulnerabilities", "VulnerabilityAnalysis")
+ ProductPackage = apps.get_model("product_portfolio", "ProductPackage")
+
+ # One query: exact (product_package_id, vulnerability_id) pairs to process.
+ # Filtering by __id__in on the M2M restricts the JOIN rows to the matching
+ # vulnerabilities, so values_list returns only the pairs we want.
+ product_package_vulnerability_pairs = set(
+ ProductPackage.objects.filter(
+ product=product,
+ package__affected_by_vulnerabilities__id__in=vulnerability_ids,
+ )
+ .values_list("id", "package__affected_by_vulnerabilities__id")
+ .distinct()
+ )
+
+ if not product_package_vulnerability_pairs:
+ return
+
+ product_package_ids = {pair_pp_id for pair_pp_id, _ in product_package_vulnerability_pairs}
+
+ # One query: all existing analyses for this product_package / vulnerability set
+ existing_analyses = {
+ (analysis.product_package_id, analysis.vulnerability_id): analysis
+ for analysis in VulnerabilityAnalysis.objects.filter(
+ product_package_id__in=product_package_ids,
+ vulnerability_id__in=vulnerability_ids,
+ )
+ }
+
+ # One query: product_package instances needed to construct new analyses
+ product_packages_by_id = {
+ product_package.pk: product_package
+ for product_package in ProductPackage.objects.filter(pk__in=product_package_ids)
+ }
+
+ for product_package_id, vulnerability_id in product_package_vulnerability_pairs:
+ existing = existing_analyses.get((product_package_id, vulnerability_id))
+
+ if existing is not None and existing.applied_by_preset_id is None:
+ continue # Human-owned analysis -- never overwrite
+
+ if existing is None:
+ product_package = product_packages_by_id[product_package_id]
+ analysis = VulnerabilityAnalysis(
+ product_package=product_package,
+ vulnerability_id=vulnerability_id,
+ dataspace_id=product.dataspace_id,
+ )
+ preset.apply_to_analysis(analysis)
+ if not analysis.has_content_fields():
+ continue # Preset has no content fields - cannot save a new analysis
+ else:
+ analysis = existing
+ preset.apply_to_analysis(analysis)
+
+ analysis.applied_by_preset = preset
+ analysis.save()
+
+
+def delete_preset_analyses_for_product(preset_id, product, vulnerability_ids):
+ """
+ Delete VulnerabilityAnalysis records applied by the given preset for the given
+ product and vulnerability set.
+ """
+ VulnerabilityAnalysis = apps.get_model("vulnerabilities", "VulnerabilityAnalysis")
+ ProductPackage = apps.get_model("product_portfolio", "ProductPackage")
+
+ product_package_ids = list(
+ ProductPackage.objects.filter(product=product).values_list("id", flat=True)
+ )
+ VulnerabilityAnalysis.objects.filter(
+ product_package_id__in=product_package_ids,
+ vulnerability_id__in=vulnerability_ids,
+ applied_by_preset_id=preset_id,
+ ).delete()
+
+
+def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id, apply_preset=True):
+ """
+ Create or update one TriageRecord per matching vulnerability, then
+ delete records for vulnerabilities that no longer match any rule in the ruleset.
+ Applies the ruleset's analysis_preset when configured and apply_preset is True.
+ """
+ now = timezone.now()
+ records = [
+ TriageRecord(
+ vulnerability_id=vulnerability_id,
+ product=product,
+ ruleset=ruleset,
+ recommended_action=ruleset.recommended_action,
+ matched_rules=matched_rules,
+ dataspace=ruleset.dataspace,
+ detected_date=now,
+ last_checked=now,
+ )
+ for vulnerability_id, matched_rules in matched_rules_per_vulnerability_id.items()
+ ]
+ TriageRecord.objects.bulk_create(
+ records,
+ update_conflicts=True,
+ unique_fields=["vulnerability", "product", "ruleset"],
+ update_fields=["recommended_action", "matched_rules", "last_checked", "dataspace"],
+ )
+
+ stale_records_qs = TriageRecord.objects.filter(
+ ruleset=ruleset,
+ product=product,
+ ).exclude(vulnerability_id__in=matched_rules_per_vulnerability_id.keys())
+
+ if ruleset.analysis_preset_id:
+ stale_vulnerability_ids = list(stale_records_qs.values_list("vulnerability_id", flat=True))
+ if stale_vulnerability_ids:
+ delete_preset_analyses_for_product(
+ preset_id=ruleset.analysis_preset_id,
+ product=product,
+ vulnerability_ids=stale_vulnerability_ids,
+ )
+
+ # Records with an open Request are kept so a later rematch reconnects to it (via the
+ # update_conflicts upsert above) instead of opening a duplicate Request.
+ stale_records_qs.filter(request__isnull=True).delete()
+
+ if apply_preset and ruleset.analysis_preset_id and matched_rules_per_vulnerability_id:
+ apply_preset_for_vulnerabilities(
+ preset=ruleset.analysis_preset,
+ product=product,
+ vulnerability_ids=list(matched_rules_per_vulnerability_id.keys()),
+ )
+
+ # Open a workflow Request for each TriageRecord when the Ruleset request_template is set.
+ if ruleset.request_template_id:
+ records_without_request = TriageRecord.objects.filter(
+ ruleset=ruleset,
+ product=product,
+ request=None,
+ ).select_related("vulnerability")
+
+ if records_without_request:
+ create_triage_requests(
+ request_template=ruleset.request_template,
+ product=product,
+ records=records_without_request,
+ )
+
+
+def create_triage_requests(request_template, product, records):
+ """
+ Open one Request per TriageRecord using the given request_template.
+
+ Uses the request_template creator as requester.
+ """
+ requester = request_template.created_by
+
+ for record in records:
+ advisory_id = record.vulnerability.advisory_id
+ triage_request = request_template.create_request(
+ requester=requester,
+ title=f"Vulnerability: {advisory_id}",
+ product_context=product,
+ object_id=product.pk,
+ )
+ TriageRecord.objects.filter(pk=record.pk).update(request=triage_request)
+
+
+def evaluate_ruleset(ruleset, product, apply_preset=True):
+ """Evaluate a TriageRuleset against a product and persist the results."""
+ matched_rules_per_vulnerability_id = collect_matches(ruleset=ruleset, product=product)
+ sync_triage_records(
+ ruleset=ruleset,
+ product=product,
+ matched_rules_per_vulnerability_id=matched_rules_per_vulnerability_id,
+ apply_preset=apply_preset,
+ )
diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py
new file mode 100644
index 00000000..ba3a084a
--- /dev/null
+++ b/vulnerabilities/triage/forms.py
@@ -0,0 +1,139 @@
+#
+# 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.core.exceptions import ValidationError
+
+from dje.forms import DataspacedAdminForm
+from vulnerabilities.models import VulnerabilityAnalysisContentMixin
+from vulnerabilities.triage.models import AnalysisPreset
+from vulnerabilities.triage.models import TriageAction
+from vulnerabilities.triage.models import TriageRuleset
+from vulnerabilities.triage.rules import RULE_REGISTRY
+
+
+class AnalysisPresetForm(DataspacedAdminForm):
+ responses = forms.MultipleChoiceField(
+ choices=VulnerabilityAnalysisContentMixin.Response.choices,
+ widget=forms.CheckboxSelectMultiple,
+ required=False,
+ )
+
+ class Meta:
+ model = AnalysisPreset
+ fields = [
+ "name",
+ "description",
+ "state",
+ "justification",
+ "responses",
+ "detail",
+ "is_reachable",
+ ]
+
+ def clean(self):
+ main_fields = ["state", "justification", "responses", "detail"]
+ if not any(self.cleaned_data.get(field_name) for field_name in main_fields):
+ raise ValidationError(
+ "At least one of state, justification, responses or detail must be provided."
+ )
+
+
+class TriageRulesetForm(DataspacedAdminForm):
+ recommended_action = forms.ChoiceField(
+ choices=[("", "---------")] + list(TriageAction.choices),
+ required=False,
+ )
+
+ class Meta:
+ model = TriageRuleset
+ fields = [
+ "name",
+ "description",
+ "recommended_action",
+ "precedence",
+ "enabled",
+ "analysis_preset",
+ "request_template",
+ ]
+
+ def clean_request_template(self):
+ request_template = self.cleaned_data.get("request_template")
+ if request_template and not request_template.created_by_id:
+ raise ValidationError(
+ "This request template has no creator and cannot be used to open requests."
+ )
+ return request_template
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.add_rule_fields()
+
+ 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():
+ rule_config = config.get(rule_type, {})
+ 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=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)
+ 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():
+ 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": 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:
+ 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
+
+ def save(self, commit=True):
+ self.instance.rules_config = self.build_rules_config()
+ return super().save(commit=commit)
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/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py
new file mode 100644
index 00000000..003a7d87
--- /dev/null
+++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py
@@ -0,0 +1,213 @@
+#
+# 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 AnalysisPreset
+from vulnerabilities.triage.models import TriageAction
+from vulnerabilities.triage.models import TriageRuleset
+
+REFERENCE_PRESETS = [
+ {
+ "name": "Auto-Close - Dev Only",
+ "description": (
+ "Automatically close vulnerabilities that affect only non-deployed packages."
+ ),
+ "state": "not_affected",
+ "justification": "code_not_present",
+ "responses": ["will_not_fix"],
+ "detail": "Package not deployed in production. Automatically closed by triage.",
+ "ruleset_name": "Dev-Only Vulnerable Package",
+ },
+ {
+ "name": "Flag - Active Exploit",
+ "description": (
+ "Flag vulnerabilities with a known active exploit for immediate human review."
+ ),
+ "state": "in_triage",
+ "detail": "Known exploit detected. Flagged for immediate review by triage.",
+ "ruleset_name": "Active Exploit",
+ },
+ {
+ "name": "Flag - Stale Vulnerability",
+ "description": (
+ "Flag high-risk vulnerabilities unaddressed beyond the configured threshold."
+ ),
+ "state": "in_triage",
+ "detail": "Vulnerability unaddressed beyond configured threshold. Escalated by triage.",
+ "ruleset_name": "Stale Vulnerability",
+ },
+]
+
+REFERENCE_RULESETS = [
+ {
+ "name": "Critical Exploited Vulnerability",
+ "description": (
+ "Vulnerabilities with a critical risk score and a known active exploit"
+ " affecting the product."
+ ),
+ "recommended_action": TriageAction.UPGRADE,
+ "precedence": 700,
+ "rules_config": {
+ "risk_score": {"is_active": True, "min_risk_score": 8.0},
+ "exploited_vulnerability": {"is_active": True},
+ },
+ },
+ {
+ "name": "Active Exploit",
+ "description": (
+ "Vulnerabilities with a known active exploit affecting the product,"
+ " regardless of severity."
+ ),
+ "recommended_action": TriageAction.UPGRADE,
+ "precedence": 600,
+ "rules_config": {
+ "exploited_vulnerability": {"is_active": True},
+ },
+ },
+ {
+ "name": "Reachable Vulnerability",
+ "description": "Vulnerabilities confirmed as reachable within the product context.",
+ "recommended_action": TriageAction.APPLY_PATCH,
+ "precedence": 500,
+ "rules_config": {
+ "reachable_vulnerability": {"is_active": True},
+ },
+ },
+ {
+ "name": "Critical Vulnerability",
+ "description": (
+ "Vulnerabilities with a critical risk score and no known active exploit"
+ " affecting the product."
+ ),
+ "recommended_action": TriageAction.APPLY_PATCH,
+ "precedence": 400,
+ "rules_config": {
+ "risk_score": {"is_active": True, "min_risk_score": 8.0},
+ },
+ },
+ {
+ "name": "Stale Vulnerability",
+ "description": (
+ "Vulnerabilities with a critical risk score left unaddressed"
+ " for more than 30 days in the product."
+ ),
+ "recommended_action": TriageAction.APPLY_PATCH,
+ "precedence": 300,
+ "rules_config": {
+ "stale_vulnerability": {"is_active": True, "min_risk_score": 8.0, "max_days": 30},
+ },
+ },
+ {
+ "name": "Dev-Only Vulnerable Package",
+ "description": "Vulnerabilities affecting only non-deployed packages in the product.",
+ "recommended_action": TriageAction.NOTIFY,
+ "precedence": 200,
+ "rules_config": {
+ "dev_only_vulnerable_package": {"is_active": True},
+ },
+ },
+ {
+ "name": "Unresolved Vulnerability",
+ "description": (
+ "Vulnerabilities affecting the product where at least one package"
+ " has no completed triage analysis."
+ ),
+ "recommended_action": TriageAction.FORENSIC_ANALYSIS,
+ "precedence": 100,
+ "rules_config": {
+ "unresolved_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.")
+ parser.add_argument(
+ "--reset",
+ action="store_true",
+ help=(
+ "Delete all existing triage rulesets in the dataspace before recreating them."
+ " This also removes all associated product assignments and triage records."
+ ),
+ )
+
+ def handle(self, *args, **options):
+ dataspace_name = options["dataspace"]
+
+ try:
+ dataspace = Dataspace.objects.get(name=dataspace_name)
+ except Dataspace.DoesNotExist:
+ raise CommandError(f'Dataspace "{dataspace_name}" does not exist.')
+
+ if options["reset"]:
+ deleted_rulesets, _ = TriageRuleset.objects.filter(dataspace=dataspace).delete()
+ deleted_presets, _ = AnalysisPreset.objects.filter(dataspace=dataspace).delete()
+ self.stdout.write(
+ f" Deleted {deleted_rulesets} existing ruleset(s) and {deleted_presets} preset(s)."
+ )
+
+ ruleset_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"],
+ "recommended_action": ruleset_data["recommended_action"],
+ "precedence": ruleset_data["precedence"],
+ "rules_config": ruleset_data["rules_config"],
+ "enabled": True,
+ },
+ )
+ if created:
+ ruleset_created_count += 1
+ self.stdout.write(f" Created: {ruleset_data['name']}")
+ else:
+ self.stdout.write(f" Already exists: {ruleset_data['name']}")
+
+ preset_created_count = 0
+ for preset_data in REFERENCE_PRESETS:
+ ruleset_name = preset_data["ruleset_name"]
+ preset, preset_created = AnalysisPreset.objects.get_or_create(
+ dataspace=dataspace,
+ name=preset_data["name"],
+ defaults={
+ "description": preset_data.get("description", ""),
+ "state": preset_data.get("state", ""),
+ "justification": preset_data.get("justification", ""),
+ "responses": preset_data.get("responses"),
+ "detail": preset_data.get("detail", ""),
+ },
+ )
+ if preset_created:
+ preset_created_count += 1
+ self.stdout.write(f" Created preset: {preset_data['name']}")
+ else:
+ self.stdout.write(f" Already exists: {preset_data['name']}")
+ try:
+ ruleset = TriageRuleset.objects.get(dataspace=dataspace, name=ruleset_name)
+ if ruleset.analysis_preset_id != preset.pk:
+ ruleset.analysis_preset = preset
+ ruleset.save(update_fields=["analysis_preset"])
+ self.stdout.write(f" Linked preset to ruleset: {ruleset_name}")
+ except TriageRuleset.DoesNotExist:
+ self.stdout.write(f" Ruleset not found: {ruleset_name}")
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"{ruleset_created_count} ruleset(s) and {preset_created_count} preset(s)"
+ f" created in dataspace '{dataspace_name}'."
+ )
+ )
diff --git a/vulnerabilities/triage/management/commands/evaluate_triage.py b/vulnerabilities/triage/management/commands/evaluate_triage.py
new file mode 100644
index 00000000..34f078ea
--- /dev/null
+++ b/vulnerabilities/triage/management/commands/evaluate_triage.py
@@ -0,0 +1,57 @@
+#
+# 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.engine import evaluate_ruleset
+from vulnerabilities.triage.models import ProductTriageRuleset
+from vulnerabilities.triage.models import TriageRecord
+
+
+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.')
+
+ assignments = (
+ ProductTriageRuleset.objects.filter(
+ dataspace=dataspace,
+ ruleset__enabled=True,
+ )
+ .select_related("product", "ruleset")
+ .order_by("product__name", "product__version", "-ruleset__precedence")
+ )
+
+ assignment_count = assignments.count()
+ self.stdout.write(f"Active assignments: {assignment_count}")
+
+ if not assignment_count:
+ self.stdout.write("No active ruleset assignments found.")
+ return
+
+ for assignment in assignments:
+ self.stdout.write(f" {assignment.product} / {assignment.ruleset}")
+ evaluate_ruleset(ruleset=assignment.ruleset, product=assignment.product)
+
+ total = TriageRecord.objects.filter(dataspace=dataspace).count()
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Done: {total} vulnerability triage record(s) active in '{dataspace_name}'."
+ )
+ )
diff --git a/vulnerabilities/triage/migrations/0001_initial.py b/vulnerabilities/triage/migrations/0001_initial.py
new file mode 100644
index 00000000..02a1bdbd
--- /dev/null
+++ b/vulnerabilities/triage/migrations/0001_initial.py
@@ -0,0 +1,99 @@
+# Generated by Django 6.0.6 on 2026-08-14 15:02
+
+import django.contrib.postgres.fields
+import django.db.models.deletion
+import dje.models
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('dje', '0016_dataspaceconfiguration_policy_rules_config'),
+ ('product_portfolio', '0019_productaffectedbyvulnerability_detected_date'),
+ ('vulnerabilities', '0010_api_v3_remove_old_fields'),
+ ('workflow', '0003_externalissuelink_base_url'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='AnalysisPreset',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')),
+ ('state', models.CharField(blank=True, choices=[('resolved', 'Resolved'), ('resolved_with_pedigree', 'Resolved With Pedigree'), ('exploitable', 'Exploitable'), ('in_triage', 'In Triage'), ('false_positive', 'False Positive'), ('not_affected', 'Not Affected')], help_text='Declares the current state of an occurrence of a vulnerability, after automated or manual analysis.', max_length=25)),
+ ('justification', models.CharField(blank=True, choices=[('code_not_present', 'Code Not Present'), ('code_not_reachable', 'Code Not Reachable'), ('protected_at_perimeter', 'Protected At Perimeter'), ('protected_at_runtime', 'Protected At Runtime'), ('protected_by_compiler', 'Protected By Compiler'), ('protected_by_mitigating_control', 'Protected By Mitigating Control'), ('requires_configuration', 'Requires Configuration'), ('requires_dependency', 'Requires Dependency'), ('requires_environment', 'Requires Environment')], help_text='The rationale of why the impact analysis state was asserted.', max_length=35)),
+ ('responses', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('can_not_fix', 'Can Not Fix'), ('rollback', 'Rollback'), ('update', 'Update'), ('will_not_fix', 'Will Not Fix'), ('workaround_available', 'Workaround Available')], max_length=20), blank=True, help_text='A response to the vulnerability by the manufacturer, supplier, or project responsible for the affected component or service. More than one response is allowed. Responses are strongly encouraged for vulnerabilities where the analysis state is exploitable.', null=True)),
+ ('detail', models.TextField(blank=True, help_text='Detailed description of the impact including methods used during assessment. If a vulnerability is not exploitable, this field should include specific details on why the component or service is not impacted by this vulnerability.')),
+ ('name', models.CharField(help_text='Short name identifying this analysis preset.', max_length=100)),
+ ('description', models.TextField(blank=True, help_text='Optional description of when and why this preset is applied.')),
+ ('is_reachable', models.BooleanField(blank=True, help_text='Reachability value to set on the analysis. Leave blank to leave the field unchanged.', null=True)),
+ ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')),
+ ],
+ options={
+ 'ordering': ('name',),
+ 'unique_together': {('dataspace', 'name'), ('dataspace', 'uuid')},
+ },
+ bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name='TriageRuleset',
+ fields=[
+ ('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.', max_length=100)),
+ ('description', models.TextField(blank=True, help_text='Optional description of the purpose or scope of this ruleset.')),
+ ('recommended_action', models.CharField(blank=True, help_text="Remediation action recommended when this ruleset's conditions are met.", max_length=50)),
+ ('precedence', models.PositiveIntegerField(help_text='When multiple rulesets are assigned to a product and produce different actions, the one with the highest precedence takes effect.')),
+ ('enabled', models.BooleanField(default=True, help_text='Activate or deactivate this ruleset without deleting it.')),
+ ('rules_config', models.JSONField(blank=True, default=dict, help_text='Active rules for this ruleset, keyed by rule type.')),
+ ('analysis_preset', models.ForeignKey(blank=True, help_text='Optional preset automatically applied to matching vulnerability analyses. Only applied when no human-owned analysis exists.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_rulesets', to='vulnerabilities_triage.analysispreset')),
+ ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')),
+ ('request_template', models.ForeignKey(blank=True, help_text='Optional product-type request template. When set, the triage engine automatically opens a request for each newly detected vulnerability match.', limit_choices_to={'content_type__app_label': 'product_portfolio', 'content_type__model': 'product'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_rulesets', to='workflow.requesttemplate')),
+ ],
+ options={
+ 'ordering': ('-precedence', 'name'),
+ 'unique_together': {('dataspace', 'name'), ('dataspace', 'precedence'), ('dataspace', 'uuid')},
+ },
+ bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name='TriageRecord',
+ 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')),
+ ('recommended_action', models.CharField(blank=True, help_text='Recommended action captured at the time of evaluation.', max_length=50)),
+ ('matched_rules', models.JSONField(default=list, help_text='Rules that fired for this vulnerability during evaluation.')),
+ ('detected_date', models.DateTimeField(auto_now_add=True, help_text='Date and time when this recommendation was first generated.')),
+ ('last_checked', models.DateTimeField(auto_now=True, help_text='Date and time of the last evaluation.')),
+ ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')),
+ ('product', models.ForeignKey(help_text='The product this recommendation applies to.', on_delete=django.db.models.deletion.CASCADE, related_name='triage_records', to='product_portfolio.product')),
+ ('request', models.ForeignKey(blank=True, help_text='Request automatically opened by the triage engine for this vulnerability match.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triage_records', to='workflow.request')),
+ ('vulnerability', models.ForeignKey(help_text='The vulnerability that triggered this recommendation.', on_delete=django.db.models.deletion.CASCADE, related_name='triage_records', to='vulnerabilities.vulnerability')),
+ ('ruleset', models.ForeignKey(help_text='The ruleset that produced this action.', on_delete=django.db.models.deletion.CASCADE, related_name='triage_records', to='vulnerabilities_triage.triageruleset')),
+ ],
+ options={
+ 'ordering': ['-detected_date'],
+ 'unique_together': {('dataspace', 'uuid'), ('vulnerability', 'product', 'ruleset')},
+ },
+ bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model),
+ ),
+ 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'],
+ 'unique_together': {('dataspace', 'uuid'), ('product', 'ruleset')},
+ },
+ 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..b61974dd
--- /dev/null
+++ b/vulnerabilities/triage/models.py
@@ -0,0 +1,239 @@
+#
+# 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.db.models import OuterRef
+from django.db.models import Subquery
+from django.utils.translation import gettext_lazy as _
+
+from dje.models import DataspacedManager
+from dje.models import DataspacedModel
+from dje.models import ProductSecuredQuerySet
+from vulnerabilities.models import VulnerabilityAnalysisContentMixin
+
+
+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 AnalysisPreset(DataspacedModel, VulnerabilityAnalysisContentMixin):
+ """Default VulnerabilityAnalysis values applied automatically when a TriageRuleset fires."""
+
+ name = models.CharField(
+ max_length=100,
+ help_text=_("Short name identifying this analysis preset."),
+ )
+ description = models.TextField(
+ blank=True,
+ help_text=_("Optional description of when and why this preset is applied."),
+ )
+ is_reachable = models.BooleanField(
+ null=True,
+ blank=True,
+ help_text=_(
+ "Reachability value to set on the analysis. Leave blank to leave the field unchanged."
+ ),
+ )
+
+ class Meta:
+ unique_together = (("dataspace", "name"), ("dataspace", "uuid"))
+ ordering = ("name",)
+
+ def __str__(self):
+ return self.name
+
+ def apply_to_analysis(self, analysis):
+ """Copy non-blank preset fields onto the analysis instance (does not save)."""
+ for field_name in ("state", "justification", "responses", "detail"):
+ if value := getattr(self, field_name):
+ setattr(analysis, field_name, value)
+ if self.is_reachable is not None:
+ analysis.is_reachable = self.is_reachable
+
+
+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."),
+ )
+ recommended_action = models.CharField(
+ max_length=50,
+ blank=True,
+ help_text=_("Remediation action recommended when this ruleset's conditions are met."),
+ )
+ precedence = models.PositiveIntegerField(
+ help_text=_(
+ "When multiple rulesets are assigned to a product and produce different"
+ " actions, the one with the highest precedence takes effect."
+ ),
+ )
+ enabled = models.BooleanField(
+ default=True,
+ help_text=_("Activate or deactivate this ruleset without deleting it."),
+ )
+ rules_config = models.JSONField(
+ default=dict,
+ blank=True,
+ help_text=_("Active rules for this ruleset, keyed by rule type."),
+ )
+ analysis_preset = models.ForeignKey(
+ to="AnalysisPreset",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="triage_rulesets",
+ help_text=_(
+ "Optional preset automatically applied to matching vulnerability analyses."
+ " Only applied when no human-owned analysis exists."
+ ),
+ )
+ request_template = models.ForeignKey(
+ to="workflow.RequestTemplate",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="triage_rulesets",
+ limit_choices_to={
+ "content_type__app_label": "product_portfolio",
+ "content_type__model": "product",
+ },
+ help_text=_(
+ "Optional product-type request template. When set, the triage engine"
+ " automatically opens a request for each newly detected vulnerability match."
+ ),
+ )
+
+ class Meta:
+ unique_together = (
+ ("dataspace", "name"),
+ ("dataspace", "precedence"),
+ ("dataspace", "uuid"),
+ )
+ ordering = ("-precedence", "name")
+
+ def __str__(self):
+ return self.name
+
+
+class TriageRecordQuerySet(ProductSecuredQuerySet):
+ def highest_precedence(self):
+ """
+ Return one record per (vulnerability, product): the highest-precedence active ruleset
+ that is explicitly assigned to the product via ProductTriageRuleset.
+
+ 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(
+ vulnerability=OuterRef("vulnerability"),
+ product=OuterRef("product"),
+ ruleset__enabled=True,
+ ruleset__product_triage_rulesets__product=OuterRef("product"),
+ )
+ .order_by("-ruleset__precedence")
+ .values("ruleset_id")[:1]
+ )
+ return self.filter(
+ ruleset__enabled=True,
+ ruleset_id=Subquery(winning_ruleset_id),
+ )
+
+
+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"]
+
+ def __str__(self):
+ return f"{self.product} / {self.ruleset}"
+
+
+class TriageRecord(DataspacedModel):
+ """Stores the triage recommendation for a specific vulnerability within a product."""
+
+ vulnerability = models.ForeignKey(
+ to="vulnerabilities.Vulnerability",
+ on_delete=models.CASCADE,
+ related_name="triage_records",
+ help_text=_("The vulnerability that triggered this recommendation."),
+ )
+ product = models.ForeignKey(
+ to="product_portfolio.Product",
+ on_delete=models.CASCADE,
+ related_name="triage_records",
+ help_text=_("The product this recommendation applies to."),
+ )
+ ruleset = models.ForeignKey(
+ to="vulnerabilities_triage.TriageRuleset",
+ on_delete=models.CASCADE,
+ related_name="triage_records",
+ help_text=_("The ruleset that produced this action."),
+ )
+ recommended_action = models.CharField(
+ max_length=50,
+ blank=True,
+ help_text=_("Recommended action captured at the time of evaluation."),
+ )
+ matched_rules = models.JSONField(
+ default=list,
+ help_text=_("Rules that fired for this vulnerability during evaluation."),
+ )
+ request = models.ForeignKey(
+ to="workflow.Request",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="triage_records",
+ help_text=_(
+ "Request automatically opened by the triage engine for this vulnerability match."
+ ),
+ )
+ detected_date = models.DateTimeField(
+ auto_now_add=True,
+ help_text=_("Date and time when this recommendation was first generated."),
+ )
+ last_checked = models.DateTimeField(
+ auto_now=True,
+ help_text=_("Date and time of the last evaluation."),
+ )
+
+ objects = DataspacedManager.from_queryset(TriageRecordQuerySet)()
+
+ class Meta:
+ unique_together = [("vulnerability", "product", "ruleset"), ("dataspace", "uuid")]
+ ordering = ["-detected_date"]
+
+ def __str__(self):
+ return f"{self.vulnerability} / {self.product} / {self.ruleset}: {self.recommended_action}"
diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py
new file mode 100644
index 00000000..0aa51286
--- /dev/null
+++ b/vulnerabilities/triage/rules.py
@@ -0,0 +1,243 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from datetime import timedelta
+
+from django.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
+
+# Not shared with policy.rules.TERMINAL_VULNERABILITY_STATES: the two lists are
+# intentionally scoped to their own engine and are not guaranteed to stay identical.
+TRIAGE_TERMINAL_VULNERABILITY_STATES = [
+ "resolved",
+ "resolved_with_pedigree",
+ "not_affected",
+ "false_positive",
+]
+
+
+class BaseTriageRule(BaseRule):
+ """Base class for vulnerability triage rule handlers."""
+
+ parameters_schema = {}
+
+ def get_matching_vulnerabilities(self, product, parameters=None):
+ raise NotImplementedError
+
+
+class RiskScoreTriageRule(BaseTriageRule):
+ rule_type = "risk_score"
+ label = "Risk Score"
+ description = "Vulnerabilities at or above the configured risk score affecting the product."
+ parameters_schema = {
+ "min_risk_score": {
+ "default": 8.0,
+ "help_text": "Minimum vulnerability risk score (0.0-10.0). Default: 8.0.",
+ },
+ }
+
+ def get_matching_vulnerabilities(self, product, parameters=None):
+ Vulnerability = apps.get_model("vulnerabilities", "Vulnerability")
+ min_risk_score = (parameters or {}).get(
+ "min_risk_score", self.parameters_schema["min_risk_score"]["default"]
+ )
+ return Vulnerability.objects.filter(
+ affected_packages__productpackages__product=product,
+ risk_score__gte=min_risk_score,
+ ).distinct()
+
+
+class WeightedRiskTriageRule(BaseTriageRule):
+ rule_type = "weighted_risk"
+ label = "Weighted Risk"
+ description = (
+ "Vulnerabilities affecting at least one package whose weighted risk score"
+ " in this product meets the threshold."
+ )
+ parameters_schema = {
+ "min_weighted_risk_score": {
+ "default": 8.0,
+ "help_text": "Minimum weighted risk score (0.0-10.0). Default: 8.0.",
+ },
+ }
+
+ def get_matching_vulnerabilities(self, product, parameters=None):
+ Vulnerability = apps.get_model("vulnerabilities", "Vulnerability")
+ min_weighted_risk_score = (parameters or {}).get(
+ "min_weighted_risk_score",
+ self.parameters_schema["min_weighted_risk_score"]["default"],
+ )
+ return Vulnerability.objects.filter(
+ affected_packages__productpackages__product=product,
+ affected_packages__productpackages__weighted_risk_score__gte=min_weighted_risk_score,
+ ).distinct()
+
+
+class ExploitedVulnerabilityTriageRule(BaseTriageRule):
+ rule_type = "exploited_vulnerability"
+ label = "Exploited Vulnerability"
+ description = "Vulnerabilities for which a known exploit is available."
+
+ def get_matching_vulnerabilities(self, product, parameters=None):
+ Vulnerability = apps.get_model("vulnerabilities", "Vulnerability")
+ # exploitability == 2.0 means known exploits are available
+ return Vulnerability.objects.filter(
+ affected_packages__productpackages__product=product,
+ exploitability=2.0,
+ ).distinct()
+
+
+class ReachableVulnerabilityTriageRule(BaseTriageRule):
+ rule_type = "reachable_vulnerability"
+ label = "Reachable Vulnerability"
+ description = "Vulnerabilities confirmed as reachable in the product context."
+
+ def get_matching_vulnerabilities(self, product, parameters=None):
+ Vulnerability = apps.get_model("vulnerabilities", "Vulnerability")
+ VulnerabilityAnalysis = apps.get_model("vulnerabilities", "vulnerabilityanalysis")
+ reachable_analysis = VulnerabilityAnalysis.objects.filter(
+ product_package__product=product,
+ vulnerability=OuterRef("pk"),
+ is_reachable=True,
+ )
+ return (
+ Vulnerability.objects.filter(affected_packages__productpackages__product=product)
+ .filter(Exists(reachable_analysis))
+ .distinct()
+ )
+
+
+class UnresolvedVulnerabilityTriageRule(BaseTriageRule):
+ rule_type = "unresolved_vulnerability"
+ label = "Unresolved Vulnerability"
+ description = (
+ "Vulnerabilities affecting the product where at least one package"
+ " has no completed triage analysis."
+ )
+
+ def get_matching_vulnerabilities(self, product, parameters=None):
+ Vulnerability = apps.get_model("vulnerabilities", "Vulnerability")
+ ProductPackage = apps.get_model("product_portfolio", "productpackage")
+ VulnerabilityAnalysis = apps.get_model("vulnerabilities", "vulnerabilityanalysis")
+ # A terminal analysis for the (product_package, vulnerability) pair
+ terminal_analysis = VulnerabilityAnalysis.objects.filter(
+ product_package=OuterRef("pk"),
+ vulnerability=OuterRef(OuterRef("pk")),
+ state__in=TRIAGE_TERMINAL_VULNERABILITY_STATES,
+ )
+ # A package in the product that carries this vulnerability but has no terminal analysis
+ unresolved_package = ProductPackage.objects.filter(
+ product=product,
+ package__affected_by_vulnerabilities=OuterRef("pk"),
+ ).filter(~Exists(terminal_analysis))
+ return (
+ Vulnerability.objects.filter(
+ affected_packages__productpackages__product=product,
+ )
+ .filter(Exists(unresolved_package))
+ .distinct()
+ )
+
+
+class StaleVulnerabilityTriageRule(BaseTriageRule):
+ rule_type = "stale_vulnerability"
+ label = "Stale Vulnerability"
+ description = (
+ "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 (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 get_matching_vulnerabilities(self, product, parameters=None):
+ Vulnerability = apps.get_model("vulnerabilities", "Vulnerability")
+ PackageAffectedByVulnerability = apps.get_model(
+ "component_catalog", "packageaffectedbyvulnerability"
+ )
+ ProductPackage = apps.get_model("product_portfolio", "productpackage")
+ VulnerabilityAnalysis = apps.get_model("vulnerabilities", "vulnerabilityanalysis")
+ parameters = parameters or {}
+ min_risk_score = parameters.get(
+ "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)
+ stale_detection_vuln_ids = PackageAffectedByVulnerability.objects.filter(
+ package__productpackages__product=product,
+ detected_date__lte=cutoff_date,
+ ).values_list("vulnerability_id", flat=True)
+ terminal_analysis = VulnerabilityAnalysis.objects.filter(
+ product_package=OuterRef("pk"),
+ vulnerability=OuterRef(OuterRef("pk")),
+ state__in=TRIAGE_TERMINAL_VULNERABILITY_STATES,
+ )
+ unresolved_package = ProductPackage.objects.filter(
+ product=product,
+ package__affected_by_vulnerabilities=OuterRef("pk"),
+ ).filter(~Exists(terminal_analysis))
+
+ return (
+ Vulnerability.objects.filter(
+ affected_packages__productpackages__product=product,
+ risk_score__gte=min_risk_score,
+ id__in=stale_detection_vuln_ids,
+ )
+ .filter(Exists(unresolved_package))
+ .distinct()
+ )
+
+
+class DevOnlyPackageTriageRule(BaseTriageRule):
+ rule_type = "dev_only_vulnerable_package"
+ label = "Dev-Only Vulnerable Package"
+ description = "Vulnerabilities affecting only non-deployed packages in the product."
+
+ def get_matching_vulnerabilities(self, product, parameters=None):
+ Vulnerability = apps.get_model("vulnerabilities", "Vulnerability")
+ deployed_vuln_ids = Vulnerability.objects.filter(
+ affected_packages__productpackages__product=product,
+ affected_packages__productpackages__is_deployed=True,
+ ).values_list("id", flat=True)
+ return (
+ Vulnerability.objects.filter(
+ affected_packages__productpackages__product=product,
+ affected_packages__productpackages__is_deployed=False,
+ )
+ .exclude(id__in=deployed_vuln_ids)
+ .distinct()
+ )
+
+
+RULE_REGISTRY = {
+ 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(),
+}
+
+
+def rule_parameters_from_config(config):
+ """Extract rule-specific parameters from a rule config dict, excluding is_active."""
+ return {key: value for key, value in config.items() if key != "is_active"}
diff --git a/vulnerabilities/triage/signals.py b/vulnerabilities/triage/signals.py
new file mode 100644
index 00000000..7db616ab
--- /dev/null
+++ b/vulnerabilities/triage/signals.py
@@ -0,0 +1,82 @@
+#
+# 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_delete
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+from vulnerabilities.triage.engine import delete_preset_analyses_for_product
+from vulnerabilities.triage.engine import evaluate_ruleset
+from vulnerabilities.triage.models import ProductTriageRuleset
+from vulnerabilities.triage.models import TriageRecord
+
+
+def reevaluate_product_rulesets(product, apply_preset=True):
+ """Re-evaluate all enabled triage rulesets currently assigned to the product."""
+ assignments = ProductTriageRuleset.objects.filter(
+ product=product, ruleset__enabled=True
+ ).select_related("ruleset", "ruleset__analysis_preset")
+
+ for assignment in assignments:
+ evaluate_ruleset(ruleset=assignment.ruleset, product=product, apply_preset=apply_preset)
+
+
+@receiver(post_save, sender="vulnerabilities_triage.TriageRuleset")
+def reevaluate_or_delete_on_ruleset_save(sender, instance, created, **kwargs):
+ """Re-evaluate assigned products on config change; delete records when disabled."""
+ if not instance.enabled:
+ # Records with an open Request are kept so re-enabling the ruleset reconnects to it
+ # instead of opening a duplicate Request.
+ instance.triage_records.filter(request__isnull=True).delete()
+ return
+
+ if created:
+ return
+
+ for assignment in instance.product_triage_rulesets.select_related("product"):
+ evaluate_ruleset(ruleset=instance, product=assignment.product)
+
+
+@receiver(post_delete, sender="vulnerabilities_triage.ProductTriageRuleset")
+def delete_triage_records_on_unassign(sender, instance, **kwargs):
+ """Delete triage records and associated preset analyses when a ruleset is de-assigned."""
+ matching_records = TriageRecord.objects.filter(
+ ruleset=instance.ruleset,
+ product=instance.product,
+ )
+ stale_vulnerability_ids = list(matching_records.values_list("vulnerability_id", flat=True))
+ # Records with an open Request are kept so reassigning the ruleset reconnects to it
+ # instead of opening a duplicate Request.
+ matching_records.filter(request__isnull=True).delete()
+ if instance.ruleset.analysis_preset_id and stale_vulnerability_ids:
+ delete_preset_analyses_for_product(
+ preset_id=instance.ruleset.analysis_preset_id,
+ product=instance.product,
+ vulnerability_ids=stale_vulnerability_ids,
+ )
+
+
+@receiver([post_save, post_delete], sender="vulnerabilities.VulnerabilityAnalysis")
+def reevaluate_on_analysis_change(sender, instance, **kwargs):
+ """Re-evaluate triage when an analysis state or reachability is updated."""
+ signal = kwargs.get("signal")
+ if signal == post_save and instance.applied_by_preset_id:
+ return # Written by the triage engine itself -- re-evaluating would loop
+ # When a human explicitly deletes their analysis, skip preset application to avoid
+ # having the engine immediately recreate it.
+ is_human_delete = signal == post_delete and not instance.applied_by_preset_id
+ reevaluate_product_rulesets(
+ instance.product_package.product,
+ apply_preset=not is_human_delete,
+ )
+
+
+@receiver([post_save, post_delete], sender="product_portfolio.ProductPackage")
+def reevaluate_on_product_package_change(sender, instance, **kwargs):
+ """Re-evaluate triage when a package is added, removed, or updated in a product."""
+ reevaluate_product_rulesets(instance.product)
diff --git a/vulnerabilities/triage/tasks.py b/vulnerabilities/triage/tasks.py
new file mode 100644
index 00000000..aa387235
--- /dev/null
+++ b/vulnerabilities/triage/tasks.py
@@ -0,0 +1,42 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+import logging
+
+from django_rq import job
+
+from vulnerabilities.triage.engine import evaluate_ruleset
+from vulnerabilities.triage.models import ProductTriageRuleset
+
+logger = logging.getLogger(__name__)
+
+
+@job
+def evaluate_all_products_vulnerability_triage_task():
+ """Evaluate all enabled triage rulesets against their assigned products."""
+ assignments = (
+ ProductTriageRuleset.objects.filter(ruleset__enabled=True)
+ .select_related("product", "ruleset", "dataspace")
+ .order_by("dataspace__name", "product__name", "product__version", "-ruleset__precedence")
+ )
+
+ count = assignments.count()
+ logger.info(f"Starting triage evaluation for {count} ruleset assignment(s).")
+
+ for assignment in assignments:
+ logger.info(f"Evaluating triage ruleset {assignment.ruleset} for {assignment.product}")
+ try:
+ evaluate_ruleset(ruleset=assignment.ruleset, product=assignment.product)
+ except Exception:
+ logger.exception(
+ f"Triage evaluation failed for {assignment.ruleset} / {assignment.product},"
+ " skipping."
+ )
+ continue
+
+ logger.info("Triage evaluation complete.")
diff --git a/vulnerabilities/triage/tests/__init__.py b/vulnerabilities/triage/tests/__init__.py
new file mode 100644
index 00000000..71589a3f
--- /dev/null
+++ b/vulnerabilities/triage/tests/__init__.py
@@ -0,0 +1,59 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+import random
+
+from dje.tests import make_string
+from vulnerabilities.triage.models import AnalysisPreset
+from vulnerabilities.triage.models import ProductTriageRuleset
+from vulnerabilities.triage.models import TriageRuleset
+
+
+def make_triage_ruleset(dataspace, **data):
+ """Create a TriageRuleset for test purposes."""
+ if "name" not in data:
+ data["name"] = f"ruleset-{make_string(10)}"
+
+ if "precedence" not in data:
+ # `precedence` is unique per dataspace: randomize the default so tests creating
+ # several rulesets without an explicit precedence do not collide with each other.
+ data["precedence"] = random.randint(1, 1_000_000)
+
+ return TriageRuleset.objects.create(
+ dataspace=dataspace,
+ **data,
+ )
+
+
+def make_analysis_preset(dataspace, **data):
+ """Create an AnalysisPreset for test purposes."""
+ if "name" not in data:
+ data["name"] = f"preset-{make_string(10)}"
+
+ if not any(data.get(field) for field in ("state", "justification", "responses", "detail")):
+ data["state"] = AnalysisPreset.State.NOT_AFFECTED
+
+ return AnalysisPreset.objects.create(
+ dataspace=dataspace,
+ **data,
+ )
+
+
+def make_product_triage_ruleset(product, ruleset=None, **data):
+ """Activate a TriageRuleset for the given product."""
+ dataspace = product.dataspace
+
+ if not ruleset:
+ ruleset = make_triage_ruleset(dataspace)
+
+ return ProductTriageRuleset.objects.create(
+ product=product,
+ ruleset=ruleset,
+ dataspace=dataspace,
+ **data,
+ )
diff --git a/vulnerabilities/triage/tests/test_engine.py b/vulnerabilities/triage/tests/test_engine.py
new file mode 100644
index 00000000..8d59e672
--- /dev/null
+++ b/vulnerabilities/triage/tests/test_engine.py
@@ -0,0 +1,411 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+
+from component_catalog.tests import make_package
+from dje.models import Dataspace
+from dje.tests import create_user
+from product_portfolio.models import Product
+from product_portfolio.tests import make_product
+from product_portfolio.tests import make_product_package
+from vulnerabilities.models import VulnerabilityAnalysis
+from vulnerabilities.tests import make_vulnerability
+from vulnerabilities.tests import make_vulnerability_analysis
+from vulnerabilities.triage.engine import apply_preset_for_vulnerabilities
+from vulnerabilities.triage.engine import collect_matches
+from vulnerabilities.triage.engine import create_triage_requests
+from vulnerabilities.triage.engine import delete_preset_analyses_for_product
+from vulnerabilities.triage.engine import evaluate_ruleset
+from vulnerabilities.triage.engine import sync_triage_records
+from vulnerabilities.triage.models import AnalysisPreset
+from vulnerabilities.triage.models import TriageAction
+from vulnerabilities.triage.models import TriageRecord
+from vulnerabilities.triage.tests import make_analysis_preset
+from vulnerabilities.triage.tests import make_triage_ruleset
+from workflow.models import Request
+from workflow.models import RequestTemplate
+
+
+class CollectMatchesTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.package = make_package(self.dataspace)
+ make_product_package(self.product, package=self.package)
+
+ def test_collects_vulnerabilities_matching_an_active_rule(self):
+ vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0)
+ ruleset = make_triage_ruleset(
+ self.dataspace,
+ rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}},
+ )
+ matches = collect_matches(ruleset, self.product)
+ self.assertEqual({vulnerability.pk: ["risk_score"]}, matches)
+
+ def test_skips_inactive_rule(self):
+ make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0)
+ ruleset = make_triage_ruleset(
+ self.dataspace,
+ rules_config={"risk_score": {"is_active": False, "min_risk_score": 8.0}},
+ )
+ matches = collect_matches(ruleset, self.product)
+ self.assertEqual({}, matches)
+
+ def test_skips_unknown_rule_type(self):
+ make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0)
+ ruleset = make_triage_ruleset(
+ self.dataspace,
+ rules_config={"not_a_real_rule": {"is_active": True}},
+ )
+ matches = collect_matches(ruleset, self.product)
+ self.assertEqual({}, matches)
+
+ def test_lists_every_rule_type_that_matches_the_same_vulnerability(self):
+ vulnerability = make_vulnerability(
+ self.dataspace, affecting=self.package, risk_score=9.0, exploitability=2.0
+ )
+ ruleset = make_triage_ruleset(
+ self.dataspace,
+ rules_config={
+ "risk_score": {"is_active": True, "min_risk_score": 8.0},
+ "exploited_vulnerability": {"is_active": True},
+ },
+ )
+ matches = collect_matches(ruleset, self.product)
+ self.assertEqual({"risk_score", "exploited_vulnerability"}, set(matches[vulnerability.pk]))
+
+ def test_passes_rule_specific_parameters_to_the_handler(self):
+ vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=5.0)
+ ruleset = make_triage_ruleset(
+ self.dataspace,
+ rules_config={"risk_score": {"is_active": True, "min_risk_score": 5.0}},
+ )
+ matches = collect_matches(ruleset, self.product)
+ self.assertEqual({vulnerability.pk: ["risk_score"]}, matches)
+
+
+class ApplyPresetForVulnerabilitiesTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.package = make_package(self.dataspace)
+ self.product_package = make_product_package(self.product, package=self.package)
+ self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package)
+
+ def test_creates_a_new_analysis_from_the_preset(self):
+ preset = make_analysis_preset(
+ self.dataspace,
+ state=AnalysisPreset.State.NOT_AFFECTED,
+ detail="Not deployed",
+ )
+ apply_preset_for_vulnerabilities(preset, self.product, [self.vulnerability.pk])
+ analysis = VulnerabilityAnalysis.objects.get(
+ product_package=self.product_package, vulnerability=self.vulnerability
+ )
+ self.assertEqual(AnalysisPreset.State.NOT_AFFECTED, analysis.state)
+ self.assertEqual(preset, analysis.applied_by_preset)
+
+ def test_does_not_overwrite_a_human_owned_analysis(self):
+ make_vulnerability_analysis(self.product_package, self.vulnerability, state="exploitable")
+ preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED)
+ apply_preset_for_vulnerabilities(preset, self.product, [self.vulnerability.pk])
+ analysis = VulnerabilityAnalysis.objects.get(
+ product_package=self.product_package, vulnerability=self.vulnerability
+ )
+ self.assertEqual("exploitable", analysis.state)
+ self.assertIsNone(analysis.applied_by_preset)
+
+ def test_updates_an_existing_preset_owned_analysis(self):
+ first_preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.IN_TRIAGE)
+ apply_preset_for_vulnerabilities(first_preset, self.product, [self.vulnerability.pk])
+
+ second_preset = make_analysis_preset(
+ self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED
+ )
+ apply_preset_for_vulnerabilities(second_preset, self.product, [self.vulnerability.pk])
+
+ analysis = VulnerabilityAnalysis.objects.get(
+ product_package=self.product_package, vulnerability=self.vulnerability
+ )
+ self.assertEqual(AnalysisPreset.State.NOT_AFFECTED, analysis.state)
+ self.assertEqual(second_preset, analysis.applied_by_preset)
+ self.assertEqual(1, VulnerabilityAnalysis.objects.count())
+
+ def test_skips_creation_when_the_preset_has_no_content_field_set(self):
+ # An AnalysisPreset always requires at least one content field to be saved (see
+ # VulnerabilityAnalysisContentMixin.save), so this can only happen with an in-memory
+ # preset. This exercises the defensive guard against saving a content-less analysis.
+ content_less_preset = AnalysisPreset(dataspace=self.dataspace, is_reachable=True)
+ apply_preset_for_vulnerabilities(content_less_preset, self.product, [self.vulnerability.pk])
+ self.assertFalse(VulnerabilityAnalysis.objects.exists())
+
+ def test_does_nothing_when_no_product_package_carries_the_vulnerability(self):
+ other_package = make_package(self.dataspace)
+ other_vulnerability = make_vulnerability(self.dataspace, affecting=other_package)
+ preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED)
+ apply_preset_for_vulnerabilities(preset, self.product, [other_vulnerability.pk])
+ self.assertFalse(VulnerabilityAnalysis.objects.exists())
+
+
+class DeletePresetAnalysesForProductTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.package = make_package(self.dataspace)
+ self.product_package = make_product_package(self.product, package=self.package)
+ self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package)
+ self.preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.IN_TRIAGE)
+
+ def test_deletes_the_matching_preset_owned_analysis(self):
+ apply_preset_for_vulnerabilities(self.preset, self.product, [self.vulnerability.pk])
+ delete_preset_analyses_for_product(self.preset.pk, self.product, [self.vulnerability.pk])
+ self.assertFalse(VulnerabilityAnalysis.objects.exists())
+
+ def test_does_not_delete_analyses_owned_by_a_different_preset(self):
+ other_preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.EXPLOITABLE)
+ apply_preset_for_vulnerabilities(other_preset, self.product, [self.vulnerability.pk])
+ delete_preset_analyses_for_product(self.preset.pk, self.product, [self.vulnerability.pk])
+ self.assertTrue(VulnerabilityAnalysis.objects.exists())
+
+ def test_does_not_delete_analyses_from_another_product(self):
+ other_product = make_product(self.dataspace)
+ other_product_package = make_product_package(other_product, package=self.package)
+ make_vulnerability_analysis(
+ other_product_package,
+ self.vulnerability,
+ state="in_triage",
+ applied_by_preset=self.preset,
+ )
+ delete_preset_analyses_for_product(self.preset.pk, self.product, [self.vulnerability.pk])
+ self.assertTrue(VulnerabilityAnalysis.objects.exists())
+
+
+class SyncTriageRecordsTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.package = make_package(self.dataspace)
+ self.product_package = make_product_package(self.product, package=self.package)
+ self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package)
+ self.ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.UPGRADE)
+
+ def test_creates_one_record_per_matching_vulnerability(self):
+ sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
+ record = TriageRecord.objects.get()
+ self.assertEqual(self.vulnerability, record.vulnerability)
+ self.assertEqual(self.product, record.product)
+ self.assertEqual(self.ruleset, record.ruleset)
+ self.assertEqual(TriageAction.UPGRADE, record.recommended_action)
+ self.assertEqual(["risk_score"], record.matched_rules)
+
+ def test_reevaluation_updates_matched_rules_without_duplicating_the_record(self):
+ sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
+ first_detected_date = TriageRecord.objects.get().detected_date
+
+ sync_triage_records(
+ self.ruleset,
+ self.product,
+ {self.vulnerability.pk: ["risk_score", "exploited_vulnerability"]},
+ )
+
+ self.assertEqual(1, TriageRecord.objects.count())
+ record = TriageRecord.objects.get()
+ self.assertEqual(["risk_score", "exploited_vulnerability"], record.matched_rules)
+ self.assertEqual(first_detected_date, record.detected_date)
+
+ def test_deletes_records_for_vulnerabilities_that_no_longer_match(self):
+ sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
+ sync_triage_records(self.ruleset, self.product, {})
+ self.assertFalse(TriageRecord.objects.exists())
+
+ def test_keeps_a_stale_record_that_has_an_open_request(self):
+ # A record with a Request already attached must survive going stale, so a later
+ # rematch reconnects to the same Request instead of opening a duplicate.
+ sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
+ record = TriageRecord.objects.get()
+ request = Request.objects.create(
+ request_template=RequestTemplate.objects.create(
+ name="Template",
+ description="Header",
+ dataspace=self.dataspace,
+ content_type=ContentType.objects.get_for_model(Product),
+ created_by=create_user("requester", self.dataspace),
+ ),
+ dataspace=self.dataspace,
+ requester=create_user("requester2", self.dataspace),
+ title="Vulnerability request",
+ product_context=self.product,
+ )
+ record.request = request
+ record.save()
+
+ sync_triage_records(self.ruleset, self.product, {})
+ self.assertEqual(1, TriageRecord.objects.count())
+ record.refresh_from_db()
+ self.assertEqual(request, record.request)
+
+ sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
+ self.assertEqual(1, TriageRecord.objects.count())
+ record.refresh_from_db()
+ self.assertEqual(request, record.request)
+
+ def test_does_not_touch_records_from_another_ruleset(self):
+ other_ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.NOTIFY)
+ sync_triage_records(other_ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
+ sync_triage_records(self.ruleset, self.product, {})
+ self.assertEqual(1, TriageRecord.objects.count())
+ self.assertEqual(other_ruleset, TriageRecord.objects.get().ruleset)
+
+ def test_applies_preset_for_matching_vulnerabilities_by_default(self):
+ preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED)
+ self.ruleset.analysis_preset = preset
+ self.ruleset.save()
+ sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
+ analysis = VulnerabilityAnalysis.objects.get()
+ self.assertEqual(preset, analysis.applied_by_preset)
+
+ def test_apply_preset_false_skips_preset_application(self):
+ preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED)
+ self.ruleset.analysis_preset = preset
+ self.ruleset.save()
+ sync_triage_records(
+ self.ruleset,
+ self.product,
+ {self.vulnerability.pk: ["risk_score"]},
+ apply_preset=False,
+ )
+ self.assertFalse(VulnerabilityAnalysis.objects.exists())
+
+ def test_deletes_the_preset_analysis_of_a_now_stale_record(self):
+ preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED)
+ self.ruleset.analysis_preset = preset
+ self.ruleset.save()
+ sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
+ self.assertTrue(VulnerabilityAnalysis.objects.exists())
+
+ sync_triage_records(self.ruleset, self.product, {})
+
+ self.assertFalse(VulnerabilityAnalysis.objects.exists())
+
+ def test_opens_a_request_for_a_new_record_when_request_template_is_set(self):
+ requester = create_user("requester", self.dataspace)
+ request_template = RequestTemplate.objects.create(
+ name="Vulnerability Template",
+ description="Header",
+ dataspace=self.dataspace,
+ content_type=ContentType.objects.get_for_model(Product),
+ created_by=requester,
+ )
+ self.ruleset.request_template = request_template
+ self.ruleset.save()
+
+ sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
+
+ record = TriageRecord.objects.get()
+ self.assertIsNotNone(record.request)
+ self.assertEqual(1, Request.objects.count())
+
+ def test_does_not_reopen_a_request_for_a_record_that_already_has_one(self):
+ requester = create_user("requester", self.dataspace)
+ request_template = RequestTemplate.objects.create(
+ name="Vulnerability Template",
+ description="Header",
+ dataspace=self.dataspace,
+ content_type=ContentType.objects.get_for_model(Product),
+ created_by=requester,
+ )
+ self.ruleset.request_template = request_template
+ self.ruleset.save()
+
+ sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
+ sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
+
+ self.assertEqual(1, Request.objects.count())
+
+
+class CreateTriageRequestsTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.package = make_package(self.dataspace)
+ make_product_package(self.product, package=self.package)
+ self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package)
+ self.ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.UPGRADE)
+ self.requester = create_user("requester", self.dataspace)
+ self.request_template = RequestTemplate.objects.create(
+ name="Vulnerability Template",
+ description="Header",
+ dataspace=self.dataspace,
+ content_type=ContentType.objects.get_for_model(Product),
+ created_by=self.requester,
+ )
+
+ def test_opens_one_request_per_record_using_the_template_creator_as_requester(self):
+ record = TriageRecord.objects.create(
+ vulnerability=self.vulnerability,
+ product=self.product,
+ ruleset=self.ruleset,
+ recommended_action=self.ruleset.recommended_action,
+ dataspace=self.dataspace,
+ )
+ create_triage_requests(self.request_template, self.product, [record])
+
+ request = Request.objects.get()
+ self.assertEqual(self.requester, request.requester)
+ self.assertEqual(self.product, request.product_context)
+ self.assertIn(self.vulnerability.advisory_id, request.title)
+
+ def test_sets_the_request_on_the_triage_record(self):
+ record = TriageRecord.objects.create(
+ vulnerability=self.vulnerability,
+ product=self.product,
+ ruleset=self.ruleset,
+ recommended_action=self.ruleset.recommended_action,
+ dataspace=self.dataspace,
+ )
+ create_triage_requests(self.request_template, self.product, [record])
+ record.refresh_from_db()
+ self.assertEqual(Request.objects.get(), record.request)
+
+
+class EvaluateRulesetTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.package = make_package(self.dataspace)
+ make_product_package(self.product, package=self.package)
+
+ def test_creates_a_triage_record_end_to_end_for_a_matching_vulnerability(self):
+ vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0)
+ ruleset = make_triage_ruleset(
+ self.dataspace,
+ recommended_action=TriageAction.UPGRADE,
+ rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}},
+ )
+ evaluate_ruleset(ruleset, self.product)
+ record = TriageRecord.objects.get()
+ self.assertEqual(vulnerability, record.vulnerability)
+ self.assertEqual(["risk_score"], record.matched_rules)
+
+ def test_removes_the_record_once_the_vulnerability_no_longer_matches(self):
+ vulnerability = make_vulnerability(self.dataspace, affecting=self.package, risk_score=9.0)
+ ruleset = make_triage_ruleset(
+ self.dataspace,
+ recommended_action=TriageAction.UPGRADE,
+ rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}},
+ )
+ evaluate_ruleset(ruleset, self.product)
+ self.assertTrue(TriageRecord.objects.exists())
+
+ vulnerability.risk_score = 2.0
+ vulnerability.save()
+ evaluate_ruleset(ruleset, self.product)
+
+ self.assertFalse(TriageRecord.objects.exists())
diff --git a/vulnerabilities/triage/tests/test_forms.py b/vulnerabilities/triage/tests/test_forms.py
new file mode 100644
index 00000000..16935a18
--- /dev/null
+++ b/vulnerabilities/triage/tests/test_forms.py
@@ -0,0 +1,122 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+
+from dje.models import Dataspace
+from dje.tests import create_user
+from product_portfolio.models import Product
+from vulnerabilities.triage.forms import AnalysisPresetForm
+from vulnerabilities.triage.forms import TriageRulesetForm
+from vulnerabilities.triage.models import AnalysisPreset
+from vulnerabilities.triage.models import TriageRuleset
+from workflow.models import RequestTemplate
+
+
+class AnalysisPresetFormTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+
+ def test_rejects_a_preset_with_no_content_field_set(self):
+ # Mirrors VulnerabilityAnalysisForm.clean: is_reachable alone is not enough content
+ # to apply, and must be caught here rather than crash in AnalysisPreset.save().
+ data = {"name": "No content", "is_reachable": True}
+ form = AnalysisPresetForm(data=data, instance=AnalysisPreset(dataspace=self.dataspace))
+ self.assertFalse(form.is_valid())
+ msg = "At least one of state, justification, responses or detail must be provided."
+ self.assertEqual({"__all__": [msg]}, form.errors)
+
+ def test_accepts_a_preset_with_detail_only(self):
+ data = {"name": "Detail only", "detail": "Some detail"}
+ form = AnalysisPresetForm(data=data, instance=AnalysisPreset(dataspace=self.dataspace))
+ self.assertTrue(form.is_valid(), form.errors)
+ preset = form.save()
+ self.assertEqual("Some detail", preset.detail)
+
+
+class TriageRulesetFormTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+
+ def test_adds_a_dynamic_field_pair_for_every_registered_rule(self):
+ form = TriageRulesetForm(instance=TriageRuleset(dataspace=self.dataspace))
+ self.assertIn("rule_risk_score_enabled", form.fields)
+ self.assertIn("rule_risk_score_min_risk_score", form.fields)
+ self.assertIn("rule_exploited_vulnerability_enabled", form.fields)
+ self.assertNotIn("rule_exploited_vulnerability_min_risk_score", form.fields)
+
+ def test_save_builds_rules_config_from_the_submitted_rule_fields(self):
+ data = {
+ "name": "My Ruleset",
+ "precedence": 100,
+ "rule_risk_score_enabled": "on",
+ "rule_risk_score_min_risk_score": "7.5",
+ "rule_exploited_vulnerability_enabled": "on",
+ }
+ form = TriageRulesetForm(data=data, instance=TriageRuleset(dataspace=self.dataspace))
+ self.assertTrue(form.is_valid(), form.errors)
+
+ ruleset = form.save()
+
+ self.assertEqual(
+ {"is_active": True, "min_risk_score": 7.5}, ruleset.rules_config["risk_score"]
+ )
+ self.assertEqual({"is_active": True}, ruleset.rules_config["exploited_vulnerability"])
+
+ def test_save_drops_an_inactive_rule_that_has_no_parameters(self):
+ data = {"name": "My Ruleset", "precedence": 100}
+ form = TriageRulesetForm(data=data, instance=TriageRuleset(dataspace=self.dataspace))
+ self.assertTrue(form.is_valid(), form.errors)
+
+ ruleset = form.save()
+
+ self.assertNotIn("exploited_vulnerability", ruleset.rules_config)
+
+ def test_save_keeps_an_inactive_rule_that_has_parameters_with_their_defaults(self):
+ data = {"name": "My Ruleset", "precedence": 100}
+ form = TriageRulesetForm(data=data, instance=TriageRuleset(dataspace=self.dataspace))
+ self.assertTrue(form.is_valid(), form.errors)
+
+ ruleset = form.save()
+
+ self.assertEqual(
+ {"is_active": False, "min_risk_score": 8.0}, ruleset.rules_config["risk_score"]
+ )
+
+ def test_rejects_a_request_template_with_no_creator(self):
+ # A RequestTemplate normally always has a creator (the admin form sets it on
+ # addition), but nothing at the DB level guarantees it -- reject it here rather
+ # than let create_triage_requests crash later with an IntegrityError.
+ request_template = RequestTemplate.objects.create(
+ name="Broken Template",
+ description="Header",
+ dataspace=self.dataspace,
+ content_type=ContentType.objects.get_for_model(Product),
+ )
+ data = {"name": "My Ruleset", "precedence": 100, "request_template": request_template.pk}
+ form = TriageRulesetForm(data=data, instance=TriageRuleset(dataspace=self.dataspace))
+
+ self.assertFalse(form.is_valid())
+
+ msg = "This request template has no creator and cannot be used to open requests."
+ self.assertEqual({"request_template": [msg]}, form.errors)
+
+ def test_accepts_a_request_template_with_a_creator(self):
+ requester = create_user("requester", self.dataspace)
+ request_template = RequestTemplate.objects.create(
+ name="Valid Template",
+ description="Header",
+ dataspace=self.dataspace,
+ content_type=ContentType.objects.get_for_model(Product),
+ created_by=requester,
+ )
+ data = {"name": "My Ruleset", "precedence": 100, "request_template": request_template.pk}
+ form = TriageRulesetForm(data=data, instance=TriageRuleset(dataspace=self.dataspace))
+
+ self.assertTrue(form.is_valid(), form.errors)
diff --git a/vulnerabilities/triage/tests/test_models.py b/vulnerabilities/triage/tests/test_models.py
new file mode 100644
index 00000000..fafeb392
--- /dev/null
+++ b/vulnerabilities/triage/tests/test_models.py
@@ -0,0 +1,239 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.test import TestCase
+
+from component_catalog.tests import make_package
+from dje.models import Dataspace
+from product_portfolio.tests import make_product
+from product_portfolio.tests import make_product_package
+from vulnerabilities.models import VulnerabilityAnalysis
+from vulnerabilities.tests import make_vulnerability
+from vulnerabilities.triage.models import AnalysisPreset
+from vulnerabilities.triage.models import ProductTriageRuleset
+from vulnerabilities.triage.models import TriageAction
+from vulnerabilities.triage.models import TriageRecord
+from vulnerabilities.triage.models import TriageRuleset
+from vulnerabilities.triage.tests import make_analysis_preset
+from vulnerabilities.triage.tests import make_product_triage_ruleset
+from vulnerabilities.triage.tests import make_triage_ruleset
+
+
+class AnalysisPresetModelTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+
+ def test_save_requires_at_least_one_content_field(self):
+ # AnalysisPreset shares its `save` validation with VulnerabilityAnalysis through
+ # VulnerabilityAnalysisContentMixin: a preset that only sets `is_reachable` has no
+ # content to apply and must be rejected the same way a bare analysis would be.
+ preset = AnalysisPreset(dataspace=self.dataspace, name="No content", is_reachable=True)
+ with self.assertRaises(ValueError):
+ preset.save()
+
+ def test_save_accepts_detail_only(self):
+ preset = AnalysisPreset(dataspace=self.dataspace, name="Detail only", detail="Some detail")
+ preset.save()
+ self.assertIsNotNone(preset.pk)
+
+ def test_apply_to_analysis_copies_non_blank_fields_only(self):
+ preset = make_analysis_preset(
+ self.dataspace,
+ state=AnalysisPreset.State.NOT_AFFECTED,
+ justification="",
+ detail="Not deployed",
+ )
+ package = make_package(self.dataspace)
+ product = make_product(self.dataspace)
+ product_package = make_product_package(product, package=package)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package)
+ analysis = VulnerabilityAnalysis(
+ product_package=product_package,
+ vulnerability=vulnerability,
+ dataspace=self.dataspace,
+ justification=VulnerabilityAnalysis.Justification.CODE_NOT_REACHABLE,
+ )
+
+ preset.apply_to_analysis(analysis)
+
+ self.assertEqual(AnalysisPreset.State.NOT_AFFECTED, analysis.state)
+ self.assertEqual("Not deployed", analysis.detail)
+ # Blank preset field must not overwrite the pre-existing value on the analysis.
+ self.assertEqual(
+ VulnerabilityAnalysis.Justification.CODE_NOT_REACHABLE, analysis.justification
+ )
+
+ def test_apply_to_analysis_leaves_is_reachable_untouched_when_preset_value_is_none(self):
+ preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.IN_TRIAGE)
+ self.assertIsNone(preset.is_reachable)
+ package = make_package(self.dataspace)
+ product = make_product(self.dataspace)
+ product_package = make_product_package(product, package=package)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package)
+ analysis = VulnerabilityAnalysis(
+ product_package=product_package,
+ vulnerability=vulnerability,
+ dataspace=self.dataspace,
+ is_reachable=True,
+ )
+
+ preset.apply_to_analysis(analysis)
+
+ self.assertTrue(analysis.is_reachable)
+
+ def test_apply_to_analysis_sets_is_reachable_when_preset_value_is_false(self):
+ preset = make_analysis_preset(
+ self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED, is_reachable=False
+ )
+ package = make_package(self.dataspace)
+ product = make_product(self.dataspace)
+ product_package = make_product_package(product, package=package)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package)
+ analysis = VulnerabilityAnalysis(
+ product_package=product_package,
+ vulnerability=vulnerability,
+ dataspace=self.dataspace,
+ is_reachable=True,
+ )
+
+ preset.apply_to_analysis(analysis)
+
+ self.assertFalse(analysis.is_reachable)
+
+
+class TriageRulesetModelTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+
+ def test_str(self):
+ ruleset = make_triage_ruleset(self.dataspace, name="My Ruleset")
+ self.assertEqual("My Ruleset", str(ruleset))
+
+ def test_default_ordering_is_by_descending_precedence_then_name(self):
+ low = make_triage_ruleset(self.dataspace, name="B", precedence=100)
+ high = make_triage_ruleset(self.dataspace, name="A", precedence=200)
+ self.assertEqual([high, low], list(TriageRuleset.objects.all()))
+
+ def test_precedence_is_unique_per_dataspace(self):
+ make_triage_ruleset(self.dataspace, precedence=500)
+ with self.assertRaises(Exception):
+ make_triage_ruleset(self.dataspace, precedence=500)
+
+
+class ProductTriageRulesetModelTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.ruleset = make_triage_ruleset(self.dataspace)
+
+ def test_str(self):
+ assignment = make_product_triage_ruleset(self.product, ruleset=self.ruleset)
+ self.assertEqual(f"{self.product} / {self.ruleset}", str(assignment))
+
+ def test_product_ruleset_pair_is_unique(self):
+ make_product_triage_ruleset(self.product, ruleset=self.ruleset)
+ with self.assertRaises(Exception):
+ ProductTriageRuleset.objects.create(
+ product=self.product, ruleset=self.ruleset, dataspace=self.dataspace
+ )
+
+
+class TriageRecordModelTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.package = make_package(self.dataspace)
+ make_product_package(self.product, package=self.package)
+ self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package)
+ self.ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.NOTIFY)
+
+ def test_str(self):
+ record = TriageRecord.objects.create(
+ vulnerability=self.vulnerability,
+ product=self.product,
+ ruleset=self.ruleset,
+ recommended_action=self.ruleset.recommended_action,
+ dataspace=self.dataspace,
+ )
+ expected = (
+ f"{self.vulnerability} / {self.product} /"
+ f" {self.ruleset}: {self.ruleset.recommended_action}"
+ )
+ self.assertEqual(expected, str(record))
+
+
+class TriageRecordQuerySetHighestPrecedenceTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.package = make_package(self.dataspace)
+ make_product_package(self.product, package=self.package)
+ self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package)
+
+ def _make_record(self, ruleset):
+ return TriageRecord.objects.create(
+ vulnerability=self.vulnerability,
+ product=self.product,
+ ruleset=ruleset,
+ recommended_action=ruleset.recommended_action,
+ dataspace=self.dataspace,
+ )
+
+ def test_returns_the_record_of_the_highest_precedence_assigned_ruleset(self):
+ low_ruleset = make_triage_ruleset(
+ self.dataspace, precedence=100, recommended_action=TriageAction.NOTIFY
+ )
+ high_ruleset = make_triage_ruleset(
+ self.dataspace, precedence=900, recommended_action=TriageAction.UPGRADE
+ )
+ make_product_triage_ruleset(self.product, ruleset=low_ruleset)
+ make_product_triage_ruleset(self.product, ruleset=high_ruleset)
+ self._make_record(low_ruleset)
+ high_record = self._make_record(high_ruleset)
+
+ winning_records = TriageRecord.objects.highest_precedence()
+
+ self.assertEqual([high_record], list(winning_records))
+
+ def test_excludes_records_for_a_ruleset_not_assigned_to_the_product(self):
+ assigned_ruleset = make_triage_ruleset(self.dataspace, precedence=100)
+ unassigned_ruleset = make_triage_ruleset(self.dataspace, precedence=900)
+ make_product_triage_ruleset(self.product, ruleset=assigned_ruleset)
+ assigned_record = self._make_record(assigned_ruleset)
+ self._make_record(unassigned_ruleset)
+
+ winning_records = TriageRecord.objects.highest_precedence()
+
+ self.assertEqual([assigned_record], list(winning_records))
+
+ def test_excludes_records_for_a_disabled_ruleset(self):
+ ruleset = make_triage_ruleset(self.dataspace, enabled=False)
+ make_product_triage_ruleset(self.product, ruleset=ruleset)
+ self._make_record(ruleset)
+
+ self.assertEqual(0, TriageRecord.objects.highest_precedence().count())
+
+ def test_evaluates_precedence_independently_per_product(self):
+ other_product = make_product(self.dataspace)
+ make_product_package(other_product, package=self.package)
+ low_ruleset = make_triage_ruleset(self.dataspace, precedence=100)
+ high_ruleset = make_triage_ruleset(self.dataspace, precedence=900)
+ make_product_triage_ruleset(self.product, ruleset=low_ruleset)
+ make_product_triage_ruleset(other_product, ruleset=high_ruleset)
+ low_record = self._make_record(low_ruleset)
+ other_record = TriageRecord.objects.create(
+ vulnerability=self.vulnerability,
+ product=other_product,
+ ruleset=high_ruleset,
+ recommended_action=high_ruleset.recommended_action,
+ dataspace=self.dataspace,
+ )
+
+ winning_records = TriageRecord.objects.highest_precedence()
+
+ self.assertEqual({low_record, other_record}, set(winning_records))
diff --git a/vulnerabilities/triage/tests/test_rules.py b/vulnerabilities/triage/tests/test_rules.py
new file mode 100644
index 00000000..e0de35ec
--- /dev/null
+++ b/vulnerabilities/triage/tests/test_rules.py
@@ -0,0 +1,283 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from datetime import timedelta
+
+from django.test import TestCase
+from django.utils import timezone
+
+from component_catalog.models import PackageAffectedByVulnerability
+from component_catalog.tests import make_package
+from dje.models import Dataspace
+from product_portfolio.tests import make_product
+from product_portfolio.tests import make_product_package
+from vulnerabilities.tests import make_vulnerability
+from vulnerabilities.tests import make_vulnerability_analysis
+from vulnerabilities.triage.rules import DevOnlyPackageTriageRule
+from vulnerabilities.triage.rules import ExploitedVulnerabilityTriageRule
+from vulnerabilities.triage.rules import ReachableVulnerabilityTriageRule
+from vulnerabilities.triage.rules import RiskScoreTriageRule
+from vulnerabilities.triage.rules import StaleVulnerabilityTriageRule
+from vulnerabilities.triage.rules import UnresolvedVulnerabilityTriageRule
+from vulnerabilities.triage.rules import WeightedRiskTriageRule
+from vulnerabilities.triage.rules import rule_parameters_from_config
+
+
+class RiskScoreTriageRuleTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ def test_matches_vulnerability_at_or_above_default_threshold(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=8.0)
+ make_product_package(self.product, package=package)
+ matches = RiskScoreTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([vulnerability], list(matches))
+
+ def test_excludes_vulnerability_below_default_threshold(self):
+ package = make_package(self.dataspace)
+ make_vulnerability(self.dataspace, affecting=package, risk_score=7.9)
+ make_product_package(self.product, package=package)
+ matches = RiskScoreTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+ def test_custom_min_risk_score_parameter(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=5.0)
+ make_product_package(self.product, package=package)
+ matches = RiskScoreTriageRule().get_matching_vulnerabilities(
+ self.product, parameters={"min_risk_score": 5.0}
+ )
+ self.assertEqual([vulnerability], list(matches))
+
+ def test_ignores_vulnerabilities_affecting_packages_outside_the_product(self):
+ package = make_package(self.dataspace)
+ make_vulnerability(self.dataspace, affecting=package, risk_score=9.0)
+ matches = RiskScoreTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+
+class WeightedRiskTriageRuleTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ def test_matches_package_at_or_above_default_threshold(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=8.0)
+ make_product_package(self.product, package=package)
+ matches = WeightedRiskTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([vulnerability], list(matches))
+
+ def test_excludes_package_below_default_threshold(self):
+ package = make_package(self.dataspace)
+ make_vulnerability(self.dataspace, affecting=package, risk_score=7.9)
+ make_product_package(self.product, package=package)
+ matches = WeightedRiskTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+ def test_custom_min_weighted_risk_score_parameter(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=4.0)
+ make_product_package(self.product, package=package)
+ matches = WeightedRiskTriageRule().get_matching_vulnerabilities(
+ self.product, parameters={"min_weighted_risk_score": 4.0}
+ )
+ self.assertEqual([vulnerability], list(matches))
+
+
+class ExploitedVulnerabilityTriageRuleTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ def test_matches_vulnerability_with_known_exploits(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package, exploitability=2.0)
+ make_product_package(self.product, package=package)
+ matches = ExploitedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([vulnerability], list(matches))
+
+ def test_excludes_vulnerability_with_potential_exploits_only(self):
+ package = make_package(self.dataspace)
+ make_vulnerability(self.dataspace, affecting=package, exploitability=1.0)
+ make_product_package(self.product, package=package)
+ matches = ExploitedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+ def test_excludes_vulnerability_with_no_exploitability_set(self):
+ package = make_package(self.dataspace)
+ make_vulnerability(self.dataspace, affecting=package)
+ make_product_package(self.product, package=package)
+ matches = ExploitedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+
+class ReachableVulnerabilityTriageRuleTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ def test_matches_vulnerability_confirmed_reachable(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package)
+ product_package = make_product_package(self.product, package=package)
+ make_vulnerability_analysis(product_package, vulnerability, is_reachable=True)
+ matches = ReachableVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([vulnerability], list(matches))
+
+ def test_excludes_vulnerability_marked_not_reachable(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package)
+ product_package = make_product_package(self.product, package=package)
+ make_vulnerability_analysis(product_package, vulnerability, is_reachable=False)
+ matches = ReachableVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+ def test_excludes_vulnerability_with_no_analysis(self):
+ package = make_package(self.dataspace)
+ make_vulnerability(self.dataspace, affecting=package)
+ make_product_package(self.product, package=package)
+ matches = ReachableVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+
+class UnresolvedVulnerabilityTriageRuleTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ def test_matches_vulnerability_with_no_terminal_analysis(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package)
+ make_product_package(self.product, package=package)
+ matches = UnresolvedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([vulnerability], list(matches))
+
+ def test_excludes_vulnerability_with_resolved_analysis(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package)
+ product_package = make_product_package(self.product, package=package)
+ make_vulnerability_analysis(product_package, vulnerability, state="resolved")
+ matches = UnresolvedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+ def test_excludes_vulnerability_with_not_affected_analysis(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package)
+ product_package = make_product_package(self.product, package=package)
+ make_vulnerability_analysis(product_package, vulnerability, state="not_affected")
+ matches = UnresolvedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+ def test_matches_when_at_least_one_package_still_unresolved(self):
+ # Two packages in the product carry the same vulnerability: one is resolved, the
+ # other isn't. The vulnerability is still considered unresolved for the product.
+ package1 = make_package(self.dataspace)
+ package2 = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=[package1, package2])
+ product_package1 = make_product_package(self.product, package=package1)
+ make_product_package(self.product, package=package2)
+ make_vulnerability_analysis(product_package1, vulnerability, state="resolved")
+ matches = UnresolvedVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([vulnerability], list(matches))
+
+
+class StaleVulnerabilityTriageRuleTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ def _age_detection(self, package, vulnerability, days):
+ old_date = timezone.now() - timedelta(days=days)
+ PackageAffectedByVulnerability.objects.filter(
+ package=package, vulnerability=vulnerability
+ ).update(detected_date=old_date)
+
+ def test_matches_old_high_risk_unresolved_vulnerability(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=9.0)
+ make_product_package(self.product, package=package)
+ self._age_detection(package, vulnerability, days=60)
+ matches = StaleVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([vulnerability], list(matches))
+
+ def test_excludes_recent_vulnerability(self):
+ package = make_package(self.dataspace)
+ make_vulnerability(self.dataspace, affecting=package, risk_score=9.0)
+ make_product_package(self.product, package=package)
+ matches = StaleVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+ def test_excludes_vulnerability_below_min_risk_score(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=3.0)
+ make_product_package(self.product, package=package)
+ self._age_detection(package, vulnerability, days=60)
+ matches = StaleVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+ def test_excludes_vulnerability_with_terminal_analysis(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=9.0)
+ product_package = make_product_package(self.product, package=package)
+ self._age_detection(package, vulnerability, days=60)
+ make_vulnerability_analysis(product_package, vulnerability, state="resolved")
+ matches = StaleVulnerabilityTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+ def test_custom_max_days_triggers_for_links_within_window(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=9.0)
+ make_product_package(self.product, package=package)
+ self._age_detection(package, vulnerability, days=10)
+ matches = StaleVulnerabilityTriageRule().get_matching_vulnerabilities(
+ self.product, parameters={"max_days": 5}
+ )
+ self.assertEqual([vulnerability], list(matches))
+
+
+class DevOnlyPackageTriageRuleTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ def test_matches_vulnerability_affecting_only_a_non_deployed_package(self):
+ package = make_package(self.dataspace)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package)
+ make_product_package(self.product, package=package, is_deployed=False)
+ matches = DevOnlyPackageTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([vulnerability], list(matches))
+
+ def test_excludes_vulnerability_affecting_a_deployed_package(self):
+ package = make_package(self.dataspace)
+ make_vulnerability(self.dataspace, affecting=package)
+ make_product_package(self.product, package=package, is_deployed=True)
+ matches = DevOnlyPackageTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+ def test_excludes_vulnerability_when_also_carried_by_a_deployed_package(self):
+ # Same vulnerability reaches the product through both a deployed and a non-deployed
+ # package: it is still live in production, so it must not be flagged as dev-only.
+ dev_package = make_package(self.dataspace)
+ deployed_package = make_package(self.dataspace)
+ make_vulnerability(self.dataspace, affecting=[dev_package, deployed_package])
+ make_product_package(self.product, package=dev_package, is_deployed=False)
+ make_product_package(self.product, package=deployed_package, is_deployed=True)
+ matches = DevOnlyPackageTriageRule().get_matching_vulnerabilities(self.product)
+ self.assertEqual([], list(matches))
+
+
+class RuleParametersFromConfigTestCase(TestCase):
+ def test_excludes_is_active_key(self):
+ config = {"is_active": True, "min_risk_score": 7.0}
+ self.assertEqual({"min_risk_score": 7.0}, rule_parameters_from_config(config))
+
+ def test_returns_empty_dict_when_only_is_active_present(self):
+ self.assertEqual({}, rule_parameters_from_config({"is_active": True}))
diff --git a/vulnerabilities/triage/tests/test_signals.py b/vulnerabilities/triage/tests/test_signals.py
new file mode 100644
index 00000000..989f978b
--- /dev/null
+++ b/vulnerabilities/triage/tests/test_signals.py
@@ -0,0 +1,369 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from unittest.mock import patch
+
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+
+from component_catalog.tests import make_package
+from dje.models import Dataspace
+from dje.tests import create_user
+from product_portfolio.models import Product
+from product_portfolio.tests import make_product
+from product_portfolio.tests import make_product_package
+from vulnerabilities.models import VulnerabilityAnalysis
+from vulnerabilities.tests import make_vulnerability
+from vulnerabilities.tests import make_vulnerability_analysis
+from vulnerabilities.triage.engine import apply_preset_for_vulnerabilities
+from vulnerabilities.triage.engine import evaluate_ruleset
+from vulnerabilities.triage.models import AnalysisPreset
+from vulnerabilities.triage.models import TriageAction
+from vulnerabilities.triage.models import TriageRecord
+from vulnerabilities.triage.signals import reevaluate_product_rulesets
+from vulnerabilities.triage.tests import make_analysis_preset
+from vulnerabilities.triage.tests import make_product_triage_ruleset
+from vulnerabilities.triage.tests import make_triage_ruleset
+from workflow.models import Request
+from workflow.models import RequestTemplate
+
+
+class ReevaluateProductRulesetsTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ @patch("vulnerabilities.triage.signals.evaluate_ruleset")
+ def test_evaluates_every_enabled_ruleset_assigned_to_the_product(self, mock_evaluate):
+ ruleset = make_triage_ruleset(self.dataspace, enabled=True)
+ make_product_triage_ruleset(self.product, ruleset=ruleset)
+
+ reevaluate_product_rulesets(self.product)
+
+ mock_evaluate.assert_called_once_with(
+ ruleset=ruleset, product=self.product, apply_preset=True
+ )
+
+ @patch("vulnerabilities.triage.signals.evaluate_ruleset")
+ def test_skips_disabled_ruleset_assignments(self, mock_evaluate):
+ ruleset = make_triage_ruleset(self.dataspace, enabled=False)
+ make_product_triage_ruleset(self.product, ruleset=ruleset)
+
+ reevaluate_product_rulesets(self.product)
+
+ mock_evaluate.assert_not_called()
+
+ @patch("vulnerabilities.triage.signals.evaluate_ruleset")
+ def test_apply_preset_flag_is_forwarded(self, mock_evaluate):
+ ruleset = make_triage_ruleset(self.dataspace, enabled=True)
+ make_product_triage_ruleset(self.product, ruleset=ruleset)
+
+ reevaluate_product_rulesets(self.product, apply_preset=False)
+
+ mock_evaluate.assert_called_once_with(
+ ruleset=ruleset, product=self.product, apply_preset=False
+ )
+
+
+class TriageRulesetSaveSignalTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ @patch("vulnerabilities.triage.signals.evaluate_ruleset")
+ def test_creating_a_ruleset_does_not_trigger_evaluation(self, mock_evaluate):
+ make_triage_ruleset(self.dataspace)
+ mock_evaluate.assert_not_called()
+
+ @patch("vulnerabilities.triage.signals.evaluate_ruleset")
+ def test_updating_an_enabled_assigned_ruleset_reevaluates_its_products(self, mock_evaluate):
+ ruleset = make_triage_ruleset(self.dataspace)
+ make_product_triage_ruleset(self.product, ruleset=ruleset)
+ mock_evaluate.reset_mock()
+
+ ruleset.description = "updated"
+ ruleset.save()
+
+ mock_evaluate.assert_called_once_with(ruleset=ruleset, product=self.product)
+
+ @patch("vulnerabilities.triage.signals.evaluate_ruleset")
+ def test_updating_an_unassigned_ruleset_does_not_trigger_evaluation(self, mock_evaluate):
+ ruleset = make_triage_ruleset(self.dataspace)
+ mock_evaluate.reset_mock()
+
+ ruleset.description = "updated"
+ ruleset.save()
+
+ mock_evaluate.assert_not_called()
+
+ @patch("vulnerabilities.triage.signals.evaluate_ruleset")
+ def test_disabling_a_ruleset_deletes_its_triage_records_instead_of_evaluating(
+ self, mock_evaluate
+ ):
+ package = make_package(self.dataspace)
+ make_product_package(self.product, package=package)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package)
+ ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.NOTIFY)
+ make_product_triage_ruleset(self.product, ruleset=ruleset)
+ TriageRecord.objects.create(
+ vulnerability=vulnerability,
+ product=self.product,
+ ruleset=ruleset,
+ recommended_action=ruleset.recommended_action,
+ dataspace=self.dataspace,
+ )
+ mock_evaluate.reset_mock()
+
+ ruleset.enabled = False
+ ruleset.save()
+
+ self.assertFalse(TriageRecord.objects.exists())
+ mock_evaluate.assert_not_called()
+
+ @patch("vulnerabilities.triage.signals.evaluate_ruleset")
+ def test_disabling_a_ruleset_keeps_records_that_have_an_open_request(self, mock_evaluate):
+ package = make_package(self.dataspace)
+ make_product_package(self.product, package=package)
+ vulnerability = make_vulnerability(self.dataspace, affecting=package)
+ ruleset = make_triage_ruleset(self.dataspace, recommended_action=TriageAction.NOTIFY)
+ make_product_triage_ruleset(self.product, ruleset=ruleset)
+ requester = create_user("requester", self.dataspace)
+ request_template = RequestTemplate.objects.create(
+ name="Template",
+ description="Header",
+ dataspace=self.dataspace,
+ content_type=ContentType.objects.get_for_model(Product),
+ created_by=requester,
+ )
+ request = request_template.create_request(
+ requester=requester,
+ title="Vulnerability request",
+ product_context=self.product,
+ object_id=self.product.pk,
+ )
+ record = TriageRecord.objects.create(
+ vulnerability=vulnerability,
+ product=self.product,
+ ruleset=ruleset,
+ recommended_action=ruleset.recommended_action,
+ request=request,
+ dataspace=self.dataspace,
+ )
+ mock_evaluate.reset_mock()
+
+ ruleset.enabled = False
+ ruleset.save()
+
+ record.refresh_from_db()
+ self.assertEqual(request, record.request)
+
+ def test_disabling_then_reenabling_a_ruleset_reuses_the_existing_request(self):
+ # Regression: disabling then re-enabling a ruleset used to reopen a new Request
+ # instead of reconnecting to the one already tracking this vulnerability.
+ package = make_package(self.dataspace)
+ make_product_package(self.product, package=package)
+ make_vulnerability(self.dataspace, affecting=package, risk_score=9.0)
+ requester = create_user("requester", self.dataspace)
+ request_template = RequestTemplate.objects.create(
+ name="Template",
+ description="Header",
+ dataspace=self.dataspace,
+ content_type=ContentType.objects.get_for_model(Product),
+ created_by=requester,
+ )
+ ruleset = make_triage_ruleset(
+ self.dataspace,
+ recommended_action=TriageAction.NOTIFY,
+ request_template=request_template,
+ rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}},
+ )
+ make_product_triage_ruleset(self.product, ruleset=ruleset)
+ evaluate_ruleset(ruleset, self.product)
+ original_request = TriageRecord.objects.get().request
+ self.assertIsNotNone(original_request)
+
+ ruleset.enabled = False
+ ruleset.save()
+ ruleset.enabled = True
+ ruleset.save()
+
+ self.assertEqual(1, Request.objects.count())
+ self.assertEqual(original_request, TriageRecord.objects.get().request)
+
+
+class DeleteTriageRecordsOnUnassignSignalTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.package = make_package(self.dataspace)
+ self.product_package = make_product_package(self.product, package=self.package)
+ self.vulnerability = make_vulnerability(
+ self.dataspace, affecting=self.package, risk_score=9.0
+ )
+ self.ruleset = make_triage_ruleset(
+ self.dataspace,
+ recommended_action=TriageAction.NOTIFY,
+ rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}},
+ )
+ self.assignment = make_product_triage_ruleset(self.product, ruleset=self.ruleset)
+ evaluate_ruleset(self.ruleset, self.product)
+ self.assertTrue(TriageRecord.objects.exists())
+
+ def test_unassigning_the_ruleset_deletes_its_triage_records_for_the_product(self):
+ self.assignment.delete()
+ self.assertFalse(TriageRecord.objects.exists())
+
+ def test_unassigning_keeps_a_record_that_has_an_open_request(self):
+ requester = create_user("requester", self.dataspace)
+ request_template = RequestTemplate.objects.create(
+ name="Template",
+ description="Header",
+ dataspace=self.dataspace,
+ content_type=ContentType.objects.get_for_model(Product),
+ created_by=requester,
+ )
+ request = request_template.create_request(
+ requester=requester,
+ title="Vulnerability request",
+ product_context=self.product,
+ object_id=self.product.pk,
+ )
+ record = TriageRecord.objects.get()
+ record.request = request
+ record.save()
+
+ self.assignment.delete()
+
+ record.refresh_from_db()
+ self.assertEqual(request, record.request)
+
+ def test_reassigning_the_ruleset_reuses_the_existing_request(self):
+ # Regression: unassigning then reassigning a ruleset used to reopen a new Request
+ # instead of reconnecting to the one already tracking this vulnerability.
+ requester = create_user("requester", self.dataspace)
+ request_template = RequestTemplate.objects.create(
+ name="Template",
+ description="Header",
+ dataspace=self.dataspace,
+ content_type=ContentType.objects.get_for_model(Product),
+ created_by=requester,
+ )
+ self.ruleset.request_template = request_template
+ self.ruleset.save()
+ original_request = TriageRecord.objects.get().request
+ self.assertIsNotNone(original_request)
+
+ self.assignment.delete()
+ make_product_triage_ruleset(self.product, ruleset=self.ruleset)
+ evaluate_ruleset(self.ruleset, self.product)
+
+ self.assertEqual(1, Request.objects.count())
+ self.assertEqual(original_request, TriageRecord.objects.get().request)
+
+ def test_does_not_delete_records_belonging_to_another_product(self):
+ other_product = make_product(self.dataspace)
+ make_product_package(other_product, package=self.package)
+ make_product_triage_ruleset(other_product, ruleset=self.ruleset)
+ evaluate_ruleset(self.ruleset, other_product)
+ other_record = TriageRecord.objects.get(product=other_product)
+
+ self.assignment.delete()
+
+ self.assertEqual([other_record], list(TriageRecord.objects.all()))
+
+ def test_deletes_the_preset_analyses_tied_to_the_deleted_records(self):
+ preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED)
+ self.ruleset.analysis_preset = preset
+ self.ruleset.save() # Re-evaluates the assigned product and auto-applies the preset.
+ self.assertTrue(VulnerabilityAnalysis.objects.exists())
+
+ self.assignment.delete()
+
+ self.assertFalse(VulnerabilityAnalysis.objects.exists())
+
+ def test_does_not_delete_human_owned_analyses(self):
+ # A human analysis set before the preset exists is never auto-applied over, so it
+ # keeps applied_by_preset=None and must survive the preset cleanup below.
+ make_vulnerability_analysis(self.product_package, self.vulnerability, state="exploitable")
+ preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED)
+ self.ruleset.analysis_preset = preset
+ self.ruleset.save()
+
+ self.assignment.delete()
+
+ analysis = VulnerabilityAnalysis.objects.get()
+ self.assertEqual("exploitable", analysis.state)
+ self.assertIsNone(analysis.applied_by_preset)
+
+
+class ReevaluateOnAnalysisChangeSignalTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+ self.package = make_package(self.dataspace)
+ self.product_package = make_product_package(self.product, package=self.package)
+ self.vulnerability = make_vulnerability(self.dataspace, affecting=self.package)
+
+ @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets")
+ def test_saving_a_human_owned_analysis_reevaluates_with_preset_application_enabled(
+ self, mock_reevaluate
+ ):
+ make_vulnerability_analysis(self.product_package, self.vulnerability, state="exploitable")
+ mock_reevaluate.assert_called_once_with(self.product, apply_preset=True)
+
+ @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets")
+ def test_saving_a_preset_applied_analysis_does_not_reevaluate(self, mock_reevaluate):
+ preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED)
+ apply_preset_for_vulnerabilities(preset, self.product, [self.vulnerability.pk])
+ mock_reevaluate.assert_not_called()
+
+ @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets")
+ def test_deleting_a_human_owned_analysis_reevaluates_with_preset_application_disabled(
+ self, mock_reevaluate
+ ):
+ analysis = make_vulnerability_analysis(
+ self.product_package, self.vulnerability, state="exploitable"
+ )
+ mock_reevaluate.reset_mock()
+
+ analysis.delete()
+
+ mock_reevaluate.assert_called_once_with(self.product, apply_preset=False)
+
+ @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets")
+ def test_deleting_a_preset_owned_analysis_reevaluates_with_preset_application_enabled(
+ self, mock_reevaluate
+ ):
+ preset = make_analysis_preset(self.dataspace, state=AnalysisPreset.State.NOT_AFFECTED)
+ apply_preset_for_vulnerabilities(preset, self.product, [self.vulnerability.pk])
+ analysis = VulnerabilityAnalysis.objects.get()
+ mock_reevaluate.reset_mock()
+
+ analysis.delete()
+
+ mock_reevaluate.assert_called_once_with(self.product, apply_preset=True)
+
+
+class ReevaluateOnProductPackageChangeSignalTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.product = make_product(self.dataspace)
+
+ @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets")
+ def test_adding_a_product_package_reevaluates_the_product(self, mock_reevaluate):
+ make_product_package(self.product)
+ mock_reevaluate.assert_called_once_with(self.product)
+
+ @patch("vulnerabilities.triage.signals.reevaluate_product_rulesets")
+ def test_removing_a_product_package_reevaluates_the_product(self, mock_reevaluate):
+ product_package = make_product_package(self.product)
+ mock_reevaluate.reset_mock()
+
+ product_package.delete()
+
+ mock_reevaluate.assert_called_once_with(self.product)
diff --git a/vulnerabilities/triage/tests/test_tasks.py b/vulnerabilities/triage/tests/test_tasks.py
new file mode 100644
index 00000000..46b7f01a
--- /dev/null
+++ b/vulnerabilities/triage/tests/test_tasks.py
@@ -0,0 +1,72 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from unittest.mock import patch
+
+from django.test import TestCase
+
+from dje.models import Dataspace
+from product_portfolio.tests import make_product
+from vulnerabilities.triage.tasks import evaluate_all_products_vulnerability_triage_task
+from vulnerabilities.triage.tests import make_product_triage_ruleset
+from vulnerabilities.triage.tests import make_triage_ruleset
+
+
+class EvaluateAllProductsVulnerabilityTriageTaskTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+
+ @patch("vulnerabilities.triage.tasks.evaluate_ruleset")
+ def test_evaluates_every_assignment_of_an_enabled_ruleset(self, mock_evaluate):
+ product = make_product(self.dataspace)
+ ruleset = make_triage_ruleset(self.dataspace, enabled=True)
+ make_product_triage_ruleset(product, ruleset=ruleset)
+
+ evaluate_all_products_vulnerability_triage_task()
+
+ mock_evaluate.assert_called_once_with(ruleset=ruleset, product=product)
+
+ @patch("vulnerabilities.triage.tasks.evaluate_ruleset")
+ def test_skips_assignments_of_a_disabled_ruleset(self, mock_evaluate):
+ product = make_product(self.dataspace)
+ ruleset = make_triage_ruleset(self.dataspace, enabled=False)
+ make_product_triage_ruleset(product, ruleset=ruleset)
+
+ evaluate_all_products_vulnerability_triage_task()
+
+ mock_evaluate.assert_not_called()
+
+ @patch("vulnerabilities.triage.tasks.evaluate_ruleset")
+ def test_continues_evaluating_remaining_assignments_after_one_raises(self, mock_evaluate):
+ failing_product = make_product(self.dataspace, name="a-product")
+ ok_product = make_product(self.dataspace, name="b-product")
+ ruleset = make_triage_ruleset(self.dataspace)
+ make_product_triage_ruleset(failing_product, ruleset=ruleset)
+ make_product_triage_ruleset(ok_product, ruleset=ruleset)
+ mock_evaluate.side_effect = [Exception("boom"), None]
+
+ with self.assertLogs("vulnerabilities.triage.tasks", level="ERROR") as captured:
+ evaluate_all_products_vulnerability_triage_task()
+
+ self.assertEqual(2, mock_evaluate.call_count)
+ mock_evaluate.assert_any_call(ruleset=ruleset, product=failing_product)
+ mock_evaluate.assert_any_call(ruleset=ruleset, product=ok_product)
+ self.assertTrue(any("Triage evaluation failed" in line for line in captured.output))
+
+ @patch("vulnerabilities.triage.tasks.evaluate_ruleset")
+ def test_evaluates_assignments_across_multiple_products(self, mock_evaluate):
+ product1 = make_product(self.dataspace)
+ product2 = make_product(self.dataspace)
+ ruleset = make_triage_ruleset(self.dataspace)
+ make_product_triage_ruleset(product1, ruleset=ruleset)
+ make_product_triage_ruleset(product2, ruleset=ruleset)
+
+ evaluate_all_products_vulnerability_triage_task()
+
+ evaluated_products = [call.kwargs["product"] for call in mock_evaluate.call_args_list]
+ self.assertEqual({product1, product2}, set(evaluated_products))