From 7f120f5adc46da8a9881913badcbf73f95b347ff Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 21 Aug 2026 10:40:21 +0200 Subject: [PATCH 1/6] add new triage rule based on SSVC decision tree Signed-off-by: tdruez --- vulnerabilities/triage/rules.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index 01f45343..05cf17a4 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -11,6 +11,7 @@ from django.apps import apps from django.db.models import Exists from django.db.models import OuterRef +from django.db.models import Q from django.utils import timezone from policy.rules import BaseRule @@ -94,6 +95,26 @@ def get_matching_vulnerabilities(self, product, parameters=None): ).distinct() +class SSVCDecisionTriageRule(BaseTriageRule): + rule_type = "ssvc_decision" + label = "SSVC Decision" + description = ( + "Vulnerabilities whose SSVC decision tree recommends Attend or Act" + " (immediate attention required)." + ) + + def get_matching_vulnerabilities(self, product, parameters=None): + Vulnerability = apps.get_model("vulnerabilities", "Vulnerability") + return ( + Vulnerability.objects.filter(affected_packages__productpackages__product=product) + .filter( + Q(ssvc_trees__contains=[{"decision": "Attend"}]) + | Q(ssvc_trees__contains=[{"decision": "Act"}]) + ) + .distinct() + ) + + class ReachableVulnerabilityTriageRule(BaseTriageRule): rule_type = "reachable_vulnerability" label = "Reachable Vulnerability" @@ -229,6 +250,7 @@ def get_matching_vulnerabilities(self, product, parameters=None): RiskScoreTriageRule.rule_type: RiskScoreTriageRule(), WeightedRiskTriageRule.rule_type: WeightedRiskTriageRule(), ExploitedVulnerabilityTriageRule.rule_type: ExploitedVulnerabilityTriageRule(), + SSVCDecisionTriageRule.rule_type: SSVCDecisionTriageRule(), ReachableVulnerabilityTriageRule.rule_type: ReachableVulnerabilityTriageRule(), UnresolvedVulnerabilityTriageRule.rule_type: UnresolvedVulnerabilityTriageRule(), StaleVulnerabilityTriageRule.rule_type: StaleVulnerabilityTriageRule(), From 08e715cf59d93a55acab8f4d0ae1cdacab162b24 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 21 Aug 2026 10:52:53 +0200 Subject: [PATCH 2/6] add preset and ruleset in management command Signed-off-by: tdruez --- .../commands/create_triage_rulesets.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/vulnerabilities/triage/management/commands/create_triage_rulesets.py b/vulnerabilities/triage/management/commands/create_triage_rulesets.py index bd3e2e2f..9356d85e 100644 --- a/vulnerabilities/triage/management/commands/create_triage_rulesets.py +++ b/vulnerabilities/triage/management/commands/create_triage_rulesets.py @@ -44,6 +44,15 @@ "detail": "Vulnerability unaddressed beyond configured threshold. Escalated by triage.", "ruleset_name": "Stale Vulnerability", }, + { + "name": "Flag - SSVC Decision", + "description": ( + "Flag vulnerabilities whose SSVC decision tree recommends immediate attention." + ), + "state": "in_triage", + "detail": "SSVC decision recommends Attend or Act. Flagged for review by triage.", + "ruleset_name": "SSVC Attend or Act", + }, ] REFERENCE_RULESETS = [ @@ -72,6 +81,18 @@ "exploited_vulnerability": {"is_active": True}, }, }, + { + "name": "SSVC Attend or Act", + "description": ( + "Vulnerabilities whose SSVC decision tree recommends immediate attention" + " (Attend or Act)." + ), + "recommended_action": TriageAction.UPGRADE, + "precedence": 550, + "rules_config": { + "ssvc_decision": {"is_active": True}, + }, + }, { "name": "Reachable Vulnerability", "description": "Vulnerabilities confirmed as reachable within the product context.", From 0b5d1534c8388f665e866d309d3feece571fa325 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 21 Aug 2026 10:58:59 +0200 Subject: [PATCH 3/6] display the highest ssvc decision in vulnerability popover Signed-off-by: tdruez --- .../includes/vulnerability_info_popover.html | 5 ++++- vulnerabilities/models.py | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html b/component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html index 667c2086..74afd23b 100644 --- a/component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html +++ b/component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html @@ -29,6 +29,9 @@
Exploitability: {{ vulnerability.get_exploitability_display }}
{% endif %} {% if vulnerability.risk_score %} -
Risk: {{ vulnerability.risk_score }}
+
Risk: {{ vulnerability.risk_score }}
+ {% endif %} + {% if vulnerability.highest_ssvc_decision %} +
SSVC: {{ vulnerability.highest_ssvc_decision }}
{% endif %} diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 8a3d89d9..5cb5ca46 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -208,6 +208,14 @@ def cve(self): if alias.startswith("CVE-"): return alias + @property + def highest_ssvc_decision(self): + """Return the most severe SSVC decision among this vulnerability's published trees.""" + decisions = {tree.get("decision") for tree in self.ssvc_trees} + for decision in ("Act", "Attend", "Track*", "Track"): + if decision in decisions: + return decision + def add_affected(self, instances, update_score=True): """Assign the ``instances`` (Package or Product) as affected by this vulnerability.""" if not isinstance(instances, (list, tuple, models.QuerySet)): From 2e4a5d62cf4dadbea5435167cf67c630c2f3375c Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 21 Aug 2026 10:59:28 +0200 Subject: [PATCH 4/6] add new SSVC rule in documentation Signed-off-by: tdruez --- docs/reference-vulnerability-triage.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/reference-vulnerability-triage.rst b/docs/reference-vulnerability-triage.rst index 5f49f2df..edebd9b9 100644 --- a/docs/reference-vulnerability-triage.rst +++ b/docs/reference-vulnerability-triage.rst @@ -19,7 +19,7 @@ that are relevant to its security program via the **Admin** interface. 1. Built-in Rules ----------------- -Seven rules are available out of the box. Each rule implements a specific detection +Eight rules are available out of the box. Each rule implements a specific detection condition evaluated against the vulnerabilities known to affect the product's packages. .. list-table:: @@ -40,6 +40,11 @@ condition evaluated against the vulnerabilities known to affect the product's pa | ``exploited_vulnerability`` - Detects vulnerabilities for which a known active exploit is available (exploitability value equals 2.0). + * - | **SSVC Decision** + | ``ssvc_decision`` + - Detects vulnerabilities whose `SSVC `_ + decision tree recommends **Attend** or **Act** (immediate attention required). + Matches if any of the vulnerability's published SSVC trees meets this threshold. * - | **Reachable Vulnerability** | ``reachable_vulnerability`` - Detects vulnerabilities confirmed as reachable in the product context: at least From e9c91227c47e60093286ed121ce234487d96a6df Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 21 Aug 2026 10:59:50 +0200 Subject: [PATCH 5/6] add unit test for new SSVC based rule Signed-off-by: tdruez --- vulnerabilities/triage/tests/test_commands.py | 6 +- vulnerabilities/triage/tests/test_rules.py | 76 +++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/vulnerabilities/triage/tests/test_commands.py b/vulnerabilities/triage/tests/test_commands.py index 11aea88f..a37936e6 100644 --- a/vulnerabilities/triage/tests/test_commands.py +++ b/vulnerabilities/triage/tests/test_commands.py @@ -37,8 +37,8 @@ def test_raises_for_a_missing_dataspace(self): def test_creates_the_reference_rulesets_and_presets(self): management.call_command("create_triage_rulesets", self.dataspace.name, stdout=StringIO()) - self.assertEqual(7, TriageRuleset.objects.filter(dataspace=self.dataspace).count()) - self.assertEqual(3, AnalysisPreset.objects.filter(dataspace=self.dataspace).count()) + self.assertEqual(8, TriageRuleset.objects.filter(dataspace=self.dataspace).count()) + self.assertEqual(4, AnalysisPreset.objects.filter(dataspace=self.dataspace).count()) def test_raises_when_rulesets_already_exist_without_reset(self): management.call_command("create_triage_rulesets", self.dataspace.name, stdout=StringIO()) @@ -79,7 +79,7 @@ def test_reset_cancelled_when_prompt_is_declined(self, mock_input): ) self.assertIn("Reset cancelled.", out.getvalue()) - self.assertEqual(7, TriageRuleset.objects.filter(dataspace=self.dataspace).count()) + self.assertEqual(8, TriageRuleset.objects.filter(dataspace=self.dataspace).count()) def test_links_each_preset_to_its_ruleset(self): management.call_command("create_triage_rulesets", self.dataspace.name, stdout=StringIO()) diff --git a/vulnerabilities/triage/tests/test_rules.py b/vulnerabilities/triage/tests/test_rules.py index e0de35ec..3ce98f65 100644 --- a/vulnerabilities/triage/tests/test_rules.py +++ b/vulnerabilities/triage/tests/test_rules.py @@ -22,6 +22,7 @@ from vulnerabilities.triage.rules import ExploitedVulnerabilityTriageRule from vulnerabilities.triage.rules import ReachableVulnerabilityTriageRule from vulnerabilities.triage.rules import RiskScoreTriageRule +from vulnerabilities.triage.rules import SSVCDecisionTriageRule from vulnerabilities.triage.rules import StaleVulnerabilityTriageRule from vulnerabilities.triage.rules import UnresolvedVulnerabilityTriageRule from vulnerabilities.triage.rules import WeightedRiskTriageRule @@ -119,6 +120,81 @@ def test_excludes_vulnerability_with_no_exploitability_set(self): self.assertEqual([], list(matches)) +class SSVCDecisionTriageRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + + @staticmethod + def _ssvc_tree(decision): + return { + "vector": "SSVCv2/E:N/A:N/T:P/P:M/B:A/M:M/D:T/2024-07-07T19:07:43Z/", + "decision": decision, + "options": [{"Exploitation": "none"}], + "source_url": "https://github.com/cisagov/vulnrichment", + } + + def test_matches_vulnerability_with_attend_decision(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability( + self.dataspace, affecting=package, ssvc_trees=[self._ssvc_tree("Attend")] + ) + make_product_package(self.product, package=package) + matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([vulnerability], list(matches)) + + def test_matches_vulnerability_with_act_decision(self): + package = make_package(self.dataspace) + vulnerability = make_vulnerability( + self.dataspace, affecting=package, ssvc_trees=[self._ssvc_tree("Act")] + ) + make_product_package(self.product, package=package) + matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([vulnerability], list(matches)) + + def test_excludes_vulnerability_with_track_decision(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package, ssvc_trees=[self._ssvc_tree("Track")]) + make_product_package(self.product, package=package) + matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_excludes_vulnerability_with_track_star_decision(self): + package = make_package(self.dataspace) + make_vulnerability( + self.dataspace, affecting=package, ssvc_trees=[self._ssvc_tree("Track*")] + ) + make_product_package(self.product, package=package) + matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_excludes_vulnerability_with_no_ssvc_trees(self): + package = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=package) + make_product_package(self.product, package=package) + matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_matches_when_at_least_one_tree_meets_the_threshold(self): + # A vulnerability can carry several SSVC trees (e.g. from different sources or + # re-evaluations). A single matching tree is enough to flag it. + package = make_package(self.dataspace) + vulnerability = make_vulnerability( + self.dataspace, + affecting=package, + ssvc_trees=[self._ssvc_tree("Track"), self._ssvc_tree("Act")], + ) + make_product_package(self.product, package=package) + matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product) + 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, ssvc_trees=[self._ssvc_tree("Act")]) + matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + class ReachableVulnerabilityTriageRuleTestCase(TestCase): def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") From e181c967bbc6d6f4031ab5456a1f9d0151e002e6 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 21 Aug 2026 13:23:01 +0200 Subject: [PATCH 6/6] add unit test for highest_ssvc_decision method Signed-off-by: tdruez --- vulnerabilities/tests/test_models.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/vulnerabilities/tests/test_models.py b/vulnerabilities/tests/test_models.py index 0aa1d497..4ddf59c3 100644 --- a/vulnerabilities/tests/test_models.py +++ b/vulnerabilities/tests/test_models.py @@ -435,3 +435,16 @@ def test_vulnerability_model_risk_level_generated_field(self): vulnerability1.save() vulnerability1.refresh_from_db() self.assertEqual("critical", vulnerability1.risk_level) + + def test_vulnerability_highest_ssvc_decision(self): + vulnerability1 = make_vulnerability(self.dataspace) + self.assertIsNone(vulnerability1.highest_ssvc_decision) + + vulnerability1.ssvc_trees = [{"decision": "Track"}] + self.assertEqual("Track", vulnerability1.highest_ssvc_decision) + + vulnerability1.ssvc_trees = [{"decision": "Track"}, {"decision": "Act"}] + self.assertEqual("Act", vulnerability1.highest_ssvc_decision) + + vulnerability1.ssvc_trees = [{"decision": "Attend"}, {"decision": "Track*"}] + self.assertEqual("Attend", vulnerability1.highest_ssvc_decision)