Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
<div class='mb-1'><strong>Exploitability:</strong> {{ vulnerability.get_exploitability_display }}</div>
{% endif %}
{% if vulnerability.risk_score %}
<div><strong>Risk:</strong> {{ vulnerability.risk_score }}</div>
<div class='mb-1'><strong>Risk:</strong> {{ vulnerability.risk_score }}</div>
{% endif %}
{% if vulnerability.highest_ssvc_decision %}
<div><strong>SSVC:</strong> {{ vulnerability.highest_ssvc_decision }}</div>
{% endif %}
</div>
7 changes: 6 additions & 1 deletion docs/reference-vulnerability-triage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::
Expand All @@ -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 <https://www.cisa.gov/ssvc-calculator>`_
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
Expand Down
8 changes: 8 additions & 0 deletions vulnerabilities/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)):
Expand Down
13 changes: 13 additions & 0 deletions vulnerabilities/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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.",
Expand Down
22 changes: 22 additions & 0 deletions vulnerabilities/triage/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 3 additions & 3 deletions vulnerabilities/triage/tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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())
Expand Down
76 changes: 76 additions & 0 deletions vulnerabilities/triage/tests/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading