Skip to content

Commit 6d3e9d7

Browse files
authored
[cravex2-reachability] Process extended reachability (#569)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 30e6864 commit 6d3e9d7

10 files changed

Lines changed: 166 additions & 12 deletions

File tree

product_portfolio/api.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ class ProductFilterSet(DataspacedAPIFilterSet):
208208
field_name="packages__affected_by_vulnerabilities__advisory_id",
209209
label="Affected by (advisory_id)",
210210
)
211+
has_reachable_vulnerability = django_filters.BooleanFilter(
212+
field_name="vulnerability_analyses__is_reachable",
213+
label="Has reachable vulnerability",
214+
distinct=True,
215+
)
211216

212217
class Meta:
213218
model = Product
@@ -226,6 +231,7 @@ class Meta:
226231
"last_modified_date",
227232
"is_vulnerable",
228233
"affected_by",
234+
"has_reachable_vulnerability",
229235
)
230236

231237

@@ -885,6 +891,11 @@ class ProductPackageFilterSet(DataspacedAPIFilterSet):
885891
field_name="package__affected_by_vulnerabilities__advisory_id",
886892
label="Affected by (advisory_id)",
887893
)
894+
has_reachable_vulnerability = django_filters.BooleanFilter(
895+
field_name="vulnerability_analyses__is_reachable",
896+
label="Has reachable vulnerability",
897+
distinct=True,
898+
)
888899

889900
class Meta:
890901
model = ProductPackage
@@ -898,6 +909,7 @@ class Meta:
898909
"last_modified_date",
899910
"is_vulnerable",
900911
"affected_by",
912+
"has_reachable_vulnerability",
901913
)
902914

903915

product_portfolio/filters.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet):
422422
("unknown", _("Reachability not known")),
423423
),
424424
)
425+
425426
triage_action = django_filters.ChoiceFilter(
426427
label=_("Triage action"),
427428
choices=TriageAction.choices,
@@ -462,6 +463,8 @@ def __init__(self, *args, **kwargs):
462463
super().__init__(*args, **kwargs)
463464
self.filters["vulnerability_analyses__state"].extra["null_label"] = "(No values)"
464465
self.filters["vulnerability_analyses__justification"].extra["null_label"] = "(No values)"
466+
is_reachable = self.filters["is_reachable"]
467+
is_reachable.extra["widget"].link_content = '<i class="fa-solid fa-circle-radiation"></i>'
465468

466469

467470
class ComponentCompletenessListFilter(admin.SimpleListFilter):

product_portfolio/importers.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from product_portfolio.models import ProductPackage
5555
from product_portfolio.models import ProductRelationStatus
5656
from product_portfolio.models import ScanCodeProject
57+
from vulnerabilities.triage.signals import reevaluate_on_analysis_change
5758
from vulnerabilities.triage.signals import reevaluate_on_product_package_change
5859
from vulnerabilities.triage.tasks import reevaluate_product_triage_rulesets_task
5960

@@ -71,24 +72,35 @@ def log_elapsed(label):
7172
@contextmanager
7273
def paused_product_package_reevaluation():
7374
"""
74-
Pause the policy and triage re-evaluation signals triggered by ProductPackage changes,
75-
for the duration of a bulk import. Call `reevaluate_products()` once the import completes
76-
to evaluate each affected product exactly once, instead of once per imported row.
75+
Pause re-evaluation signals for the duration of a bulk import.
76+
77+
Covers ProductPackage add/remove and VulnerabilityAnalysis create/update signals so that
78+
each triggers at most once per affected product. Call `reevaluate_products()` after the
79+
import to run the evaluation exactly once instead of once per imported row.
7780
"""
78-
receivers = [
81+
from vulnerabilities.models import VulnerabilityAnalysis
82+
83+
productpackage_receivers = [
7984
evaluate_product_rules_on_productpackage_change,
8085
reevaluate_on_product_package_change,
8186
]
82-
for receiver in receivers:
87+
for receiver in productpackage_receivers:
8388
post_save.disconnect(receiver, sender=ProductPackage)
8489
post_delete.disconnect(receiver, sender=ProductPackage)
90+
91+
post_save.disconnect(reevaluate_on_analysis_change, sender=VulnerabilityAnalysis)
92+
post_delete.disconnect(reevaluate_on_analysis_change, sender=VulnerabilityAnalysis)
93+
8594
try:
8695
yield
8796
finally:
88-
for receiver in receivers:
97+
for receiver in productpackage_receivers:
8998
post_save.connect(receiver, sender=ProductPackage)
9099
post_delete.connect(receiver, sender=ProductPackage)
91100

101+
post_save.connect(reevaluate_on_analysis_change, sender=VulnerabilityAnalysis)
102+
post_delete.connect(reevaluate_on_analysis_change, sender=VulnerabilityAnalysis)
103+
92104

93105
def reevaluate_products(products):
94106
"""Queue the policy and triage re-evaluation once for each of the given products."""

product_portfolio/tests/test_api.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,6 +1553,57 @@ def test_api_product_endpoint_vulnerabilities_features(self):
15531553
self.assertNotContains(response, self.product1_detail_url)
15541554
self.assertNotContains(response, self.product2_detail_url)
15551555

1556+
def test_api_productpackage_has_reachable_vulnerability_filter(self):
1557+
self.client.login(username="super_user", password="secret")
1558+
vulnerability = make_vulnerability(self.dataspace, affecting=self.package1)
1559+
make_vulnerability_analysis(self.pp1, vulnerability, is_reachable=True)
1560+
1561+
data = {"has_reachable_vulnerability": "true"}
1562+
response = self.client.get(self.productpackage_list_url, data)
1563+
self.assertEqual(1, response.data["count"])
1564+
self.assertContains(response, self.pp1_detail_url)
1565+
1566+
data = {"has_reachable_vulnerability": "false"}
1567+
response = self.client.get(self.productpackage_list_url, data)
1568+
self.assertEqual(0, response.data["count"])
1569+
1570+
def test_api_product_has_reachable_vulnerability_filter(self):
1571+
self.client.login(username="super_user", password="secret")
1572+
vulnerability = make_vulnerability(self.dataspace, affecting=self.package1)
1573+
make_vulnerability_analysis(self.pp1, vulnerability, is_reachable=True)
1574+
1575+
data = {"has_reachable_vulnerability": "true"}
1576+
response = self.client.get(self.product_list_url, data)
1577+
self.assertEqual(1, response.data["count"])
1578+
self.assertContains(response, self.product1_detail_url)
1579+
self.assertNotContains(response, self.product2_detail_url)
1580+
1581+
data = {"has_reachable_vulnerability": "false"}
1582+
response = self.client.get(self.product_list_url, data)
1583+
self.assertEqual(0, response.data["count"])
1584+
1585+
def test_api_product_has_reachable_vulnerability_filter_no_duplicates(self):
1586+
self.client.login(username="super_user", password="secret")
1587+
vulnerability1 = make_vulnerability(self.dataspace, affecting=self.package1)
1588+
vulnerability2 = make_vulnerability(self.dataspace, affecting=self.package1)
1589+
make_vulnerability_analysis(self.pp1, vulnerability1, is_reachable=True)
1590+
make_vulnerability_analysis(self.pp1, vulnerability2, is_reachable=True)
1591+
1592+
data = {"has_reachable_vulnerability": "true"}
1593+
response = self.client.get(self.product_list_url, data)
1594+
self.assertEqual(1, response.data["count"])
1595+
1596+
def test_api_productpackage_has_reachable_vulnerability_filter_no_duplicates(self):
1597+
self.client.login(username="super_user", password="secret")
1598+
vulnerability1 = make_vulnerability(self.dataspace, affecting=self.package1)
1599+
vulnerability2 = make_vulnerability(self.dataspace, affecting=self.package1)
1600+
make_vulnerability_analysis(self.pp1, vulnerability1, is_reachable=True)
1601+
make_vulnerability_analysis(self.pp1, vulnerability2, is_reachable=True)
1602+
1603+
data = {"has_reachable_vulnerability": "true"}
1604+
response = self.client.get(self.productpackage_list_url, data)
1605+
self.assertEqual(1, response.data["count"])
1606+
15561607
def test_api_codebaseresource_list_endpoint_results(self):
15571608
self.client.login(username="super_user", password="secret")
15581609
response = self.client.get(self.codebase_resource_list_url)

product_portfolio/tests/test_views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,14 @@ def test_product_portfolio_tab_vulnerability_view_filters(self):
322322
response, "?vulnerabilities-vulnerability_analyses__state=#vulnerabilities"
323323
)
324324

325+
def test_product_portfolio_tab_vulnerability_view_is_reachable_filter_in_analysis_header(self):
326+
self.client.login(username="nexb_user", password="secret")
327+
url = self.product1.get_url("tab_vulnerabilities")
328+
response = self.client.get(url)
329+
self.assertContains(response, "fa-circle-radiation")
330+
self.assertContains(response, "?vulnerabilities-is_reachable=yes#vulnerabilities")
331+
self.assertContains(response, "?vulnerabilities-is_reachable=no#vulnerabilities")
332+
325333
def test_product_portfolio_tab_vulnerability_view_packages_row_rendering(self):
326334
self.client.login(username="nexb_user", password="secret")
327335
# Each have a unique vulnerability, and p1 p2 are sharing a common one.

product_portfolio/views.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,6 +1271,17 @@ class ProductTabVulnerabilitiesView(
12711271
),
12721272
)
12731273

1274+
def get_table_headers(self):
1275+
"""Inject the is_reachable filter widget into the Analysis column header."""
1276+
headers = super().get_table_headers()
1277+
is_reachable_widget = f'<span class="me-2">{self.filterset.form["is_reachable"]}</span>'
1278+
return [
1279+
header._replace(filter=mark_safe(is_reachable_widget + str(header.filter)))
1280+
if header.field_name == "vulnerability_analyses__state"
1281+
else header
1282+
for header in headers
1283+
]
1284+
12741285
def attach_vulnerability_analyses(self, page_obj):
12751286
"""Set the matching VulnerabilityAnalysis instance on each prefetched vulnerability."""
12761287
response_labels = dict(VulnerabilityAnalysis.Response.choices)

vulnerabilities/models.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from django.utils import timezone
2020
from django.utils.translation import gettext_lazy as _
2121

22+
from cyclonedx import model as cdx_model
2223
from cyclonedx.model import vulnerability as cdx_vulnerability
2324

2425
from dje.fields import JSONListField
@@ -266,7 +267,16 @@ def as_cyclonedx(self, affected_instances, analysis=None):
266267
for instance in affected_instances
267268
]
268269

269-
analysis = analysis.as_cyclonedx() if analysis else None
270+
properties = None
271+
if analysis is not None and analysis.is_reachable is not None:
272+
properties = [
273+
cdx_model.Property(
274+
name="aboutcode:is_reachable",
275+
value="true" if analysis.is_reachable else "false",
276+
)
277+
]
278+
279+
cdx_analysis = analysis.as_cyclonedx() if analysis else None
270280

271281
source = cdx_vulnerability.VulnerabilitySource(
272282
name="VulnerableCode",
@@ -278,7 +288,8 @@ def as_cyclonedx(self, affected_instances, analysis=None):
278288
source=source,
279289
description=self.summary,
280290
affects=affects,
281-
analysis=analysis,
291+
analysis=cdx_analysis,
292+
properties=properties,
282293
)
283294

284295

vulnerabilities/tests/test_models.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,38 @@ def test_vulnerability_model_as_cyclonedx(self):
319319
}
320320
self.assertEqual(expected, as_dict["analysis"])
321321

322+
def test_vulnerability_model_as_cyclonedx_is_reachable_property(self):
323+
vulnerability = make_vulnerability(self.dataspace)
324+
package = make_package(self.dataspace)
325+
product_package = make_product_package(make_product(self.dataspace), package=package)
326+
327+
def make_analysis(is_reachable):
328+
return VulnerabilityAnalysis(
329+
product_package=product_package,
330+
vulnerability=vulnerability,
331+
dataspace=self.dataspace,
332+
state=VulnerabilityAnalysis.State.IN_TRIAGE,
333+
is_reachable=is_reachable,
334+
)
335+
336+
cdx = vulnerability.as_cyclonedx(affected_instances=[package], analysis=make_analysis(True))
337+
as_dict = json.loads(cdx.as_json())
338+
self.assertEqual(
339+
[{"name": "aboutcode:is_reachable", "value": "true"}], as_dict["properties"]
340+
)
341+
342+
cdx = vulnerability.as_cyclonedx(
343+
affected_instances=[package], analysis=make_analysis(False)
344+
)
345+
as_dict = json.loads(cdx.as_json())
346+
self.assertEqual(
347+
[{"name": "aboutcode:is_reachable", "value": "false"}], as_dict["properties"]
348+
)
349+
350+
cdx = vulnerability.as_cyclonedx(affected_instances=[package], analysis=make_analysis(None))
351+
as_dict = json.loads(cdx.as_json())
352+
self.assertNotIn("properties", as_dict)
353+
322354
def test_vulnerability_model_vulnerability_analysis_save(self):
323355
vulnerability1 = make_vulnerability(dataspace=self.dataspace)
324356
product_package1 = make_product_package(make_product(self.dataspace))

vulnerabilities/triage/management/commands/create_triage_rulesets.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@
5353
"detail": "SSVC decision recommends Attend or Act. Flagged for review by triage.",
5454
"ruleset_name": "SSVC Attend or Act",
5555
},
56+
{
57+
"name": "Flag - Reachable Vulnerability",
58+
"description": (
59+
"Flag vulnerabilities confirmed as reachable in the product for patch prioritization."
60+
),
61+
"state": "in_triage",
62+
"is_reachable": True,
63+
"detail": (
64+
"Vulnerability confirmed reachable in the product context. "
65+
"Flagged for patch prioritization."
66+
),
67+
"ruleset_name": "Reachable Vulnerability",
68+
},
5669
]
5770

5871
REFERENCE_RULESETS = [
@@ -63,7 +76,7 @@
6376
" affecting the product."
6477
),
6578
"recommended_action": TriageAction.UPGRADE,
66-
"precedence": 700,
79+
"precedence": 800,
6780
"rules_config": {
6881
"risk_score": {"is_active": True, "min_risk_score": 8.0},
6982
"exploited_vulnerability": {"is_active": True},
@@ -76,7 +89,7 @@
7689
" regardless of severity."
7790
),
7891
"recommended_action": TriageAction.UPGRADE,
79-
"precedence": 600,
92+
"precedence": 700,
8093
"rules_config": {
8194
"exploited_vulnerability": {"is_active": True},
8295
},
@@ -88,7 +101,7 @@
88101
" (Attend or Act)."
89102
),
90103
"recommended_action": TriageAction.UPGRADE,
91-
"precedence": 550,
104+
"precedence": 600,
92105
"rules_config": {
93106
"ssvc_decision": {"is_active": True},
94107
},
@@ -232,6 +245,7 @@ def handle(self, *args, **options):
232245
justification=preset_data.get("justification", ""),
233246
responses=preset_data.get("responses"),
234247
detail=preset_data.get("detail", ""),
248+
is_reachable=preset_data.get("is_reachable"),
235249
)
236250
self.stdout.write(f" Created preset: {preset_data['name']}")
237251

vulnerabilities/triage/tests/test_commands.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_creates_the_reference_rulesets_and_presets(self):
3838
management.call_command("create_triage_rulesets", self.dataspace.name, stdout=StringIO())
3939

4040
self.assertEqual(8, TriageRuleset.objects.filter(dataspace=self.dataspace).count())
41-
self.assertEqual(4, AnalysisPreset.objects.filter(dataspace=self.dataspace).count())
41+
self.assertEqual(5, 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())

0 commit comments

Comments
 (0)