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/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
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)):
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)
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.",
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(),
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")