Skip to content

Commit 293a6b4

Browse files
committed
add Triage tab in the product details view
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent e5994ad commit 293a6b4

4 files changed

Lines changed: 166 additions & 6 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{% load i18n %}
2+
3+
<div class="d-flex justify-content-between align-items-center mb-3">
4+
<div class="text-body-secondary small">
5+
{{ total_count }} package{{ total_count|pluralize }} with pending triage action{{ total_count|pluralize }}
6+
</div>
7+
{% if page_obj.paginator.num_pages > 1 %}
8+
{% include 'pagination/object_list_pagination.html' with hx_target=tab_id_html %}
9+
{% endif %}
10+
</div>
11+
12+
{% if page_obj.object_list %}
13+
<table class="table table-bordered table-md text-break">
14+
<thead>
15+
<tr>
16+
<th>{% trans "Package" %}</th>
17+
<th>{% trans "Recommended action" %}</th>
18+
<th>{% trans "Ruleset" %}</th>
19+
<th>{% trans "Rules triggered" %}</th>
20+
<th>{% trans "Detected" %}</th>
21+
</tr>
22+
</thead>
23+
<tbody>
24+
{% for record in page_obj.object_list %}
25+
<tr>
26+
<td>
27+
<strong>
28+
<a href="{{ record.product_package.package.get_absolute_url }}#vulnerabilities" target="_blank">
29+
{{ record.product_package.package }}
30+
</a>
31+
</strong>
32+
</td>
33+
<td>
34+
<span class="badge bg-warning-subtle text-warning-emphasis">
35+
{{ record.action_label }}
36+
</span>
37+
</td>
38+
<td class="text-nowrap">{{ record.ruleset.name }}</td>
39+
<td>
40+
{% for label in record.rule_labels %}
41+
<div class="small">{{ label }}</div>
42+
{% endfor %}
43+
</td>
44+
<td class="text-nowrap small text-body-secondary">{{ record.detected_date|date:"Y-m-d" }}</td>
45+
</tr>
46+
{% endfor %}
47+
</tbody>
48+
</table>
49+
{% if page_obj.paginator.num_pages > 1 %}
50+
{% include 'pagination/object_list_pagination.html' with hx_target=tab_id_html %}
51+
{% endif %}
52+
{% else %}
53+
<div class="text-center py-5">
54+
<i class="fa-solid fa-circle-check text-success fs-2"></i>
55+
<div class="mt-2 fw-semibold">{% trans "No pending triage actions" %}</div>
56+
<div class="text-body-secondary small mt-1">{% trans "All packages are within acceptable risk parameters." %}</div>
57+
</div>
58+
{% endif %}

product_portfolio/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from product_portfolio.views import ProductTabDependenciesView
3535
from product_portfolio.views import ProductTabInventoryView
3636
from product_portfolio.views import ProductTabLicensesView
37+
from product_portfolio.views import ProductTabTriageView
3738
from product_portfolio.views import ProductTabVulnerabilitiesView
3839
from product_portfolio.views import ProductTreeComparisonView
3940
from product_portfolio.views import ProductUpdateView
@@ -152,6 +153,7 @@ def product_path(path_segment, view):
152153
*product_path("tab_dependencies", ProductTabDependenciesView.as_view()),
153154
*product_path("tab_licenses", ProductTabLicensesView.as_view()),
154155
*product_path("tab_vulnerabilities", ProductTabVulnerabilitiesView.as_view()),
156+
*product_path("tab_triage", ProductTabTriageView.as_view()),
155157
*product_path("tab_activity", ProductTabActivityView.as_view()),
156158
*product_path("tab_inventory", ProductTabInventoryView.as_view()),
157159
*product_path("tab_compliance", ProductTabComplianceView.as_view()),

product_portfolio/views.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@
150150
from vulnerabilities.models import Vulnerability
151151
from vulnerabilities.models import VulnerabilityAnalysis
152152
from vulnerabilities.models import get_risk_level
153+
from vulnerabilities.triage.models import TriageAction
154+
from vulnerabilities.triage.models import TriageRecord
155+
from vulnerabilities.triage.rules import RULE_REGISTRY as TRIAGE_RULE_REGISTRY
153156

154157

155158
class BaseProductViewMixin:
@@ -295,6 +298,7 @@ class ProductDetailsView(
295298
],
296299
},
297300
"vulnerabilities": {},
301+
"triage": {},
298302
"codebase": {
299303
"fields": [
300304
"path",
@@ -666,6 +670,34 @@ def tab_vulnerabilities(self):
666670
"fields": [(None, tab_context, None, template)],
667671
}
668672

673+
def tab_triage(self):
674+
product = self.object
675+
676+
triage_count = (
677+
TriageRecord.objects.filter(product_package__product=product).primary_actions().count()
678+
)
679+
680+
badge_class = "bg-primary-subtle text-primary-emphasis"
681+
if triage_count > 0:
682+
badge_class = "bg-warning-subtle text-warning-emphasis"
683+
684+
label = f'Triage <span class="badge {badge_class}">{triage_count}</span>'
685+
686+
tab_view_url = product.get_url("tab_triage")
687+
if full_query_string := self.request.META["QUERY_STRING"]:
688+
tab_view_url += f"?{full_query_string}"
689+
690+
template = "tabs/tab_async_loader.html"
691+
tab_context = {
692+
"tab_view_url": tab_view_url,
693+
"tab_object_name": "triage actions",
694+
}
695+
696+
return {
697+
"label": mark_safe(label),
698+
"fields": [(None, tab_context, None, template)],
699+
}
700+
669701
def tab_codebase(self):
670702
codebaseresources_count = self.object.codebaseresources.count()
671703
if not codebaseresources_count:
@@ -1328,6 +1360,65 @@ def get_context_data(self, **kwargs):
13281360
return context_data
13291361

13301362

1363+
class ProductTabTriageView(
1364+
LoginRequiredMixin,
1365+
BaseProductViewMixin,
1366+
PaginationMixin,
1367+
TabContentView,
1368+
):
1369+
template_name = "product_portfolio/tabs/tab_triage.html"
1370+
paginate_by = 50
1371+
query_dict_page_param = "triage-page"
1372+
tab_id = "triage"
1373+
1374+
def get_context_data(self, **kwargs):
1375+
product = self.object
1376+
1377+
action_labels = dict(TriageAction.choices)
1378+
rule_labels = {
1379+
rule_type: handler.label for rule_type, handler in TRIAGE_RULE_REGISTRY.items()
1380+
}
1381+
1382+
triage_qs = (
1383+
TriageRecord.objects.filter(product_package__product=product)
1384+
.primary_actions()
1385+
.select_related(
1386+
"product_package__package",
1387+
"ruleset",
1388+
)
1389+
)
1390+
total_count = triage_qs.count()
1391+
1392+
paginator = Paginator(triage_qs, self.paginate_by)
1393+
page_number = self.request.GET.get(self.query_dict_page_param)
1394+
page_obj = paginator.get_page(page_number)
1395+
1396+
for record in page_obj.object_list:
1397+
record.action_label = action_labels.get(record.action, record.action)
1398+
record.rule_labels = [
1399+
rule_labels.get(rule_type, rule_type) for rule_type in record.matched_rules
1400+
]
1401+
1402+
context_data = super().get_context_data(**kwargs)
1403+
context_data.update(
1404+
{
1405+
"page_obj": page_obj,
1406+
"total_count": total_count,
1407+
}
1408+
)
1409+
1410+
if page_obj:
1411+
previous_url, next_url = self.get_previous_next(page_obj)
1412+
context_data.update(
1413+
{
1414+
"previous_url": (previous_url or "") + f"#{self.tab_id}",
1415+
"next_url": (next_url or "") + f"#{self.tab_id}",
1416+
}
1417+
)
1418+
1419+
return context_data
1420+
1421+
13311422
class ProductTabActivityView(
13321423
LoginRequiredMixin,
13331424
BaseProductViewMixin,

vulnerabilities/triage/models.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
from django.apps import apps
1010
from django.db import models
11+
from django.db.models import OuterRef
12+
from django.db.models import Subquery
1113
from django.utils.translation import gettext_lazy as _
1214

1315
from dje.models import DataspacedManager
@@ -83,13 +85,20 @@ def primary_actions(self):
8385
"""
8486
Return one record per product_package: the highest-precedence active ruleset.
8587
86-
Uses PostgreSQL DISTINCT ON to pick the winning ruleset when multiple rulesets
87-
fire for the same package. Chain after a product filter to scope the results.
88+
Uses a correlated subquery to find the winning ruleset per package rather than
89+
DISTINCT ON, which breaks under Django's COUNT wrapping and select_related JOINs.
8890
"""
89-
return (
90-
self.filter(ruleset__enabled=True)
91-
.order_by("product_package", "-ruleset__precedence")
92-
.distinct("product_package")
91+
winning_ruleset_id = (
92+
self.model.objects.filter(
93+
product_package=OuterRef("product_package"),
94+
ruleset__enabled=True,
95+
)
96+
.order_by("-ruleset__precedence")
97+
.values("ruleset_id")[:1]
98+
)
99+
return self.filter(
100+
ruleset__enabled=True,
101+
ruleset_id=Subquery(winning_ruleset_id),
93102
)
94103

95104

0 commit comments

Comments
 (0)