Skip to content

Commit d913770

Browse files
authored
[cravex2-reachability] Enhance the vulnerability-ranking system for trees (#566)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent b756179 commit d913770

8 files changed

Lines changed: 153 additions & 5 deletions

File tree

component_catalog/templates/component_catalog/includes/vulnerability_info_popover.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
<div class='mb-1'><strong>Exploitability:</strong> {{ vulnerability.get_exploitability_display }}</div>
3030
{% endif %}
3131
{% if vulnerability.risk_score %}
32-
<div><strong>Risk:</strong> {{ vulnerability.risk_score }}</div>
32+
<div class='mb-1'><strong>Risk:</strong> {{ vulnerability.risk_score }}</div>
33+
{% endif %}
34+
{% if vulnerability.highest_ssvc_decision %}
35+
<div><strong>SSVC:</strong> {{ vulnerability.highest_ssvc_decision }}</div>
3336
{% endif %}
3437
</div>

docs/reference-vulnerability-triage.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ that are relevant to its security program via the **Admin** interface.
1919
1. Built-in Rules
2020
-----------------
2121

22-
Seven rules are available out of the box. Each rule implements a specific detection
22+
Eight rules are available out of the box. Each rule implements a specific detection
2323
condition evaluated against the vulnerabilities known to affect the product's packages.
2424

2525
.. list-table::
@@ -40,6 +40,11 @@ condition evaluated against the vulnerabilities known to affect the product's pa
4040
| ``exploited_vulnerability``
4141
- Detects vulnerabilities for which a known active exploit is available
4242
(exploitability value equals 2.0).
43+
* - | **SSVC Decision**
44+
| ``ssvc_decision``
45+
- Detects vulnerabilities whose `SSVC <https://www.cisa.gov/ssvc-calculator>`_
46+
decision tree recommends **Attend** or **Act** (immediate attention required).
47+
Matches if any of the vulnerability's published SSVC trees meets this threshold.
4348
* - | **Reachable Vulnerability**
4449
| ``reachable_vulnerability``
4550
- Detects vulnerabilities confirmed as reachable in the product context: at least

vulnerabilities/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,14 @@ def cve(self):
208208
if alias.startswith("CVE-"):
209209
return alias
210210

211+
@property
212+
def highest_ssvc_decision(self):
213+
"""Return the most severe SSVC decision among this vulnerability's published trees."""
214+
decisions = {tree.get("decision") for tree in self.ssvc_trees}
215+
for decision in ("Act", "Attend", "Track*", "Track"):
216+
if decision in decisions:
217+
return decision
218+
211219
def add_affected(self, instances, update_score=True):
212220
"""Assign the ``instances`` (Package or Product) as affected by this vulnerability."""
213221
if not isinstance(instances, (list, tuple, models.QuerySet)):

vulnerabilities/tests/test_models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,16 @@ def test_vulnerability_model_risk_level_generated_field(self):
435435
vulnerability1.save()
436436
vulnerability1.refresh_from_db()
437437
self.assertEqual("critical", vulnerability1.risk_level)
438+
439+
def test_vulnerability_highest_ssvc_decision(self):
440+
vulnerability1 = make_vulnerability(self.dataspace)
441+
self.assertIsNone(vulnerability1.highest_ssvc_decision)
442+
443+
vulnerability1.ssvc_trees = [{"decision": "Track"}]
444+
self.assertEqual("Track", vulnerability1.highest_ssvc_decision)
445+
446+
vulnerability1.ssvc_trees = [{"decision": "Track"}, {"decision": "Act"}]
447+
self.assertEqual("Act", vulnerability1.highest_ssvc_decision)
448+
449+
vulnerability1.ssvc_trees = [{"decision": "Attend"}, {"decision": "Track*"}]
450+
self.assertEqual("Attend", vulnerability1.highest_ssvc_decision)

vulnerabilities/triage/management/commands/create_triage_rulesets.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@
4444
"detail": "Vulnerability unaddressed beyond configured threshold. Escalated by triage.",
4545
"ruleset_name": "Stale Vulnerability",
4646
},
47+
{
48+
"name": "Flag - SSVC Decision",
49+
"description": (
50+
"Flag vulnerabilities whose SSVC decision tree recommends immediate attention."
51+
),
52+
"state": "in_triage",
53+
"detail": "SSVC decision recommends Attend or Act. Flagged for review by triage.",
54+
"ruleset_name": "SSVC Attend or Act",
55+
},
4756
]
4857

4958
REFERENCE_RULESETS = [
@@ -72,6 +81,18 @@
7281
"exploited_vulnerability": {"is_active": True},
7382
},
7483
},
84+
{
85+
"name": "SSVC Attend or Act",
86+
"description": (
87+
"Vulnerabilities whose SSVC decision tree recommends immediate attention"
88+
" (Attend or Act)."
89+
),
90+
"recommended_action": TriageAction.UPGRADE,
91+
"precedence": 550,
92+
"rules_config": {
93+
"ssvc_decision": {"is_active": True},
94+
},
95+
},
7596
{
7697
"name": "Reachable Vulnerability",
7798
"description": "Vulnerabilities confirmed as reachable within the product context.",

vulnerabilities/triage/rules.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.apps import apps
1212
from django.db.models import Exists
1313
from django.db.models import OuterRef
14+
from django.db.models import Q
1415
from django.utils import timezone
1516

1617
from policy.rules import BaseRule
@@ -94,6 +95,26 @@ def get_matching_vulnerabilities(self, product, parameters=None):
9495
).distinct()
9596

9697

98+
class SSVCDecisionTriageRule(BaseTriageRule):
99+
rule_type = "ssvc_decision"
100+
label = "SSVC Decision"
101+
description = (
102+
"Vulnerabilities whose SSVC decision tree recommends Attend or Act"
103+
" (immediate attention required)."
104+
)
105+
106+
def get_matching_vulnerabilities(self, product, parameters=None):
107+
Vulnerability = apps.get_model("vulnerabilities", "Vulnerability")
108+
return (
109+
Vulnerability.objects.filter(affected_packages__productpackages__product=product)
110+
.filter(
111+
Q(ssvc_trees__contains=[{"decision": "Attend"}])
112+
| Q(ssvc_trees__contains=[{"decision": "Act"}])
113+
)
114+
.distinct()
115+
)
116+
117+
97118
class ReachableVulnerabilityTriageRule(BaseTriageRule):
98119
rule_type = "reachable_vulnerability"
99120
label = "Reachable Vulnerability"
@@ -229,6 +250,7 @@ def get_matching_vulnerabilities(self, product, parameters=None):
229250
RiskScoreTriageRule.rule_type: RiskScoreTriageRule(),
230251
WeightedRiskTriageRule.rule_type: WeightedRiskTriageRule(),
231252
ExploitedVulnerabilityTriageRule.rule_type: ExploitedVulnerabilityTriageRule(),
253+
SSVCDecisionTriageRule.rule_type: SSVCDecisionTriageRule(),
232254
ReachableVulnerabilityTriageRule.rule_type: ReachableVulnerabilityTriageRule(),
233255
UnresolvedVulnerabilityTriageRule.rule_type: UnresolvedVulnerabilityTriageRule(),
234256
StaleVulnerabilityTriageRule.rule_type: StaleVulnerabilityTriageRule(),

vulnerabilities/triage/tests/test_commands.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ def test_raises_for_a_missing_dataspace(self):
3737
def test_creates_the_reference_rulesets_and_presets(self):
3838
management.call_command("create_triage_rulesets", self.dataspace.name, stdout=StringIO())
3939

40-
self.assertEqual(7, TriageRuleset.objects.filter(dataspace=self.dataspace).count())
41-
self.assertEqual(3, AnalysisPreset.objects.filter(dataspace=self.dataspace).count())
40+
self.assertEqual(8, TriageRuleset.objects.filter(dataspace=self.dataspace).count())
41+
self.assertEqual(4, AnalysisPreset.objects.filter(dataspace=self.dataspace).count())
4242

4343
def test_raises_when_rulesets_already_exist_without_reset(self):
4444
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):
7979
)
8080

8181
self.assertIn("Reset cancelled.", out.getvalue())
82-
self.assertEqual(7, TriageRuleset.objects.filter(dataspace=self.dataspace).count())
82+
self.assertEqual(8, TriageRuleset.objects.filter(dataspace=self.dataspace).count())
8383

8484
def test_links_each_preset_to_its_ruleset(self):
8585
management.call_command("create_triage_rulesets", self.dataspace.name, stdout=StringIO())

vulnerabilities/triage/tests/test_rules.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from vulnerabilities.triage.rules import ExploitedVulnerabilityTriageRule
2323
from vulnerabilities.triage.rules import ReachableVulnerabilityTriageRule
2424
from vulnerabilities.triage.rules import RiskScoreTriageRule
25+
from vulnerabilities.triage.rules import SSVCDecisionTriageRule
2526
from vulnerabilities.triage.rules import StaleVulnerabilityTriageRule
2627
from vulnerabilities.triage.rules import UnresolvedVulnerabilityTriageRule
2728
from vulnerabilities.triage.rules import WeightedRiskTriageRule
@@ -119,6 +120,81 @@ def test_excludes_vulnerability_with_no_exploitability_set(self):
119120
self.assertEqual([], list(matches))
120121

121122

123+
class SSVCDecisionTriageRuleTestCase(TestCase):
124+
def setUp(self):
125+
self.dataspace = Dataspace.objects.create(name="nexB")
126+
self.product = make_product(self.dataspace)
127+
128+
@staticmethod
129+
def _ssvc_tree(decision):
130+
return {
131+
"vector": "SSVCv2/E:N/A:N/T:P/P:M/B:A/M:M/D:T/2024-07-07T19:07:43Z/",
132+
"decision": decision,
133+
"options": [{"Exploitation": "none"}],
134+
"source_url": "https://github.com/cisagov/vulnrichment",
135+
}
136+
137+
def test_matches_vulnerability_with_attend_decision(self):
138+
package = make_package(self.dataspace)
139+
vulnerability = make_vulnerability(
140+
self.dataspace, affecting=package, ssvc_trees=[self._ssvc_tree("Attend")]
141+
)
142+
make_product_package(self.product, package=package)
143+
matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product)
144+
self.assertEqual([vulnerability], list(matches))
145+
146+
def test_matches_vulnerability_with_act_decision(self):
147+
package = make_package(self.dataspace)
148+
vulnerability = make_vulnerability(
149+
self.dataspace, affecting=package, ssvc_trees=[self._ssvc_tree("Act")]
150+
)
151+
make_product_package(self.product, package=package)
152+
matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product)
153+
self.assertEqual([vulnerability], list(matches))
154+
155+
def test_excludes_vulnerability_with_track_decision(self):
156+
package = make_package(self.dataspace)
157+
make_vulnerability(self.dataspace, affecting=package, ssvc_trees=[self._ssvc_tree("Track")])
158+
make_product_package(self.product, package=package)
159+
matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product)
160+
self.assertEqual([], list(matches))
161+
162+
def test_excludes_vulnerability_with_track_star_decision(self):
163+
package = make_package(self.dataspace)
164+
make_vulnerability(
165+
self.dataspace, affecting=package, ssvc_trees=[self._ssvc_tree("Track*")]
166+
)
167+
make_product_package(self.product, package=package)
168+
matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product)
169+
self.assertEqual([], list(matches))
170+
171+
def test_excludes_vulnerability_with_no_ssvc_trees(self):
172+
package = make_package(self.dataspace)
173+
make_vulnerability(self.dataspace, affecting=package)
174+
make_product_package(self.product, package=package)
175+
matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product)
176+
self.assertEqual([], list(matches))
177+
178+
def test_matches_when_at_least_one_tree_meets_the_threshold(self):
179+
# A vulnerability can carry several SSVC trees (e.g. from different sources or
180+
# re-evaluations). A single matching tree is enough to flag it.
181+
package = make_package(self.dataspace)
182+
vulnerability = make_vulnerability(
183+
self.dataspace,
184+
affecting=package,
185+
ssvc_trees=[self._ssvc_tree("Track"), self._ssvc_tree("Act")],
186+
)
187+
make_product_package(self.product, package=package)
188+
matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product)
189+
self.assertEqual([vulnerability], list(matches))
190+
191+
def test_ignores_vulnerabilities_affecting_packages_outside_the_product(self):
192+
package = make_package(self.dataspace)
193+
make_vulnerability(self.dataspace, affecting=package, ssvc_trees=[self._ssvc_tree("Act")])
194+
matches = SSVCDecisionTriageRule().get_matching_vulnerabilities(self.product)
195+
self.assertEqual([], list(matches))
196+
197+
122198
class ReachableVulnerabilityTriageRuleTestCase(TestCase):
123199
def setUp(self):
124200
self.dataspace = Dataspace.objects.create(name="nexB")

0 commit comments

Comments
 (0)