Skip to content

Commit b309fb3

Browse files
committed
enable rulsets per products
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 72b33d9 commit b309fb3

10 files changed

Lines changed: 172 additions & 19 deletions

File tree

product_portfolio/forms.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
from product_portfolio.models import ScanCodeProject
5959
from product_portfolio.tasks import pull_project_data_from_scancodeio_task
6060
from product_portfolio.tasks import scancodeio_submit_project_task
61+
from vulnerabilities.triage.models import ProductTriageRuleset
6162

6263

6364
class NameVersionValidationFormMixin:
@@ -106,6 +107,7 @@ class ProductForm(
106107
ProductComponent,
107108
ProductPackage,
108109
CodebaseResource,
110+
ProductTriageRuleset,
109111
]
110112

111113
keywords = KeywordsField()

product_portfolio/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,9 @@ def get_export_security_compliance_url(self):
495495
def get_evaluate_policy_rules_url(self):
496496
return self.get_url("evaluate_policy_rules")
497497

498+
def get_manage_triage_rulesets_url(self):
499+
return self.get_url("manage_triage_rulesets")
500+
498501
@property
499502
def cyclonedx_bom_ref(self):
500503
return str(self.uuid)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{% load i18n %}
2+
<form id="manage-triage-rulesets-form">
3+
{% if available_rulesets %}
4+
<p class="text-body-secondary small px-3 pt-3 mb-0">
5+
{% trans "Select the rules to activate for this product. Saving triggers an evaluation of all active packages." %}
6+
</p>
7+
<div class="list-group list-group-flush mt-2">
8+
{% for ruleset in available_rulesets %}
9+
<label class="list-group-item d-flex gap-3 py-3" for="ruleset_{{ ruleset.uuid }}">
10+
<input class="form-check-input flex-shrink-0 mt-1"
11+
type="checkbox"
12+
name="ruleset_uuids"
13+
value="{{ ruleset.uuid }}"
14+
id="ruleset_{{ ruleset.uuid }}"
15+
{% if ruleset.id in assigned_ruleset_ids %}checked{% endif %}>
16+
<span class="flex-grow-1">
17+
<span class="d-flex justify-content-between align-items-baseline">
18+
<strong>{{ ruleset.name }}</strong>
19+
<span class="badge bg-secondary-subtle text-secondary-emphasis ms-2 flex-shrink-0">
20+
{% trans "Precedence:" %} {{ ruleset.precedence }}
21+
</span>
22+
</span>
23+
<span class="d-block small text-body-secondary">
24+
{{ ruleset.action_label }}{% if ruleset.description %}, {{ ruleset.description }}{% endif %}
25+
</span>
26+
</span>
27+
</label>
28+
{% endfor %}
29+
</div>
30+
{% else %}
31+
<p class="text-body-secondary px-3 py-3 mb-0">
32+
{% trans "No triage rules are available in this dataspace." %}
33+
</p>
34+
{% endif %}
35+
</form>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{% load i18n %}
2+
<div id="manage-triage-rulesets-modal" class="modal" tabindex="-1" role="dialog">
3+
<div class="modal-dialog modal-lg" role="document">
4+
<div class="modal-content">
5+
<div class="modal-header">
6+
<h5 class="modal-title">{% trans "Triage Rules" %}</h5>
7+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
8+
</div>
9+
<div id="manage-triage-rulesets-body" class="modal-body p-0">
10+
</div>
11+
<div class="modal-footer">
12+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
13+
<button type="button" id="submit-triage-rulesets" class="btn btn-primary">{% trans "Save" %}</button>
14+
</div>
15+
</div>
16+
</div>
17+
</div>

product_portfolio/templates/product_portfolio/product_details.html

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<i class="fas fa-tasks"></i> Manage
1111
</button>
1212
</span>
13-
{% elif has_edit_productcomponent or has_edit_productpackage or has_change_codebaseresource_permission or has_add_productcomponent %}
13+
{% elif has_edit_productcomponent or has_edit_productpackage or has_change_codebaseresource_permission or has_add_productcomponent or has_change_permission %}
1414
<div class="dropdown btn-group">
1515
<a class="btn btn-outline-dark dropdown-toggle" data-bs-toggle="dropdown" role="button" href="#"><i class="fas fa-tasks"></i> {% trans 'Manage' %}</a>
1616
<div class="dropdown-menu dropdown-menu-end">
@@ -32,6 +32,16 @@
3232
{% if purldb_enabled %}
3333
<a class="dropdown-item" href="#" id="check-package-versions"><i class="fas fa-arrow-alt-circle-up"></i> {% trans 'Check for new Package versions' %}</a>
3434
{% endif %}
35+
{% if has_change_permission and request.user.dataspace.enable_vulnerablecodedb_access %}
36+
<hr class="dropdown-divider">
37+
<div class="dropdown-header">{% trans "Vulnerabilities" %}</div>
38+
<button class="dropdown-item" type="button"
39+
data-bs-toggle="modal"
40+
data-bs-target="#manage-triage-rulesets-modal"
41+
data-manage-url="{{ product.get_manage_triage_rulesets_url }}">
42+
<i class="fas fa-filter me-1"></i> {% trans "Triage Rules" %}
43+
</button>
44+
{% endif %}
3545
</div>
3646
</div>
3747
{% endif %}
@@ -148,6 +158,9 @@
148158
{% if request.user.dataspace.enable_vulnerablecodedb_access and product.vulnerability_count %}
149159
{% include 'product_portfolio/modals/vulnerability_analysis_modal.html' %}
150160
{% endif %}
161+
{% if has_change_permission and request.user.dataspace.enable_vulnerablecodedb_access %}
162+
{% include 'product_portfolio/modals/manage_triage_rulesets_modal.html' %}
163+
{% endif %}
151164
{% endblock %}
152165

153166
{% block messages-alert %}
@@ -281,6 +294,39 @@
281294
</script>
282295
{% endif %}
283296

297+
{% if has_change_permission and request.user.dataspace.enable_vulnerablecodedb_access %}
298+
<script>
299+
document.addEventListener("DOMContentLoaded", function () {
300+
const triageModal = document.getElementById("manage-triage-rulesets-modal");
301+
if (!triageModal) return;
302+
303+
triageModal.addEventListener("show.bs.modal", function (event) {
304+
const body = document.getElementById("manage-triage-rulesets-body");
305+
body.innerHTML = "";
306+
const manageUrl = event.relatedTarget.dataset.manageUrl;
307+
document.getElementById("submit-triage-rulesets").dataset.manageUrl = manageUrl;
308+
fetch(manageUrl)
309+
.then(r => r.text())
310+
.then(html => { body.innerHTML = html; })
311+
.catch(() => { body.innerHTML = "Error loading content."; });
312+
});
313+
314+
document.getElementById("submit-triage-rulesets").addEventListener("click", function () {
315+
const manageUrl = this.dataset.manageUrl;
316+
const form = document.getElementById("manage-triage-rulesets-form");
317+
fetch(manageUrl, {
318+
method: "POST",
319+
headers: { "X-CSRFToken": csrftoken },
320+
body: new FormData(form),
321+
})
322+
.then(r => r.json())
323+
.then(data => { if (data.success) location.reload(); })
324+
.catch(() => {});
325+
});
326+
});
327+
</script>
328+
{% endif %}
329+
284330
{% if request.user.dataspace.enable_vulnerablecodedb_access and product.vulnerability_count %}
285331
<script>
286332
$(document).ready(function () {

product_portfolio/tests/test_views.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ def test_product_portfolio_detail_view_tab_vulnerability_queryset(self):
278278
self.client.login(username="nexb_user", password="secret")
279279
url = self.product1.get_url("tab_vulnerabilities")
280280

281-
with self.assertMaxQueries(9):
281+
with self.assertMaxQueries(12):
282282
response = self.client.get(url)
283283
self.assertContains(response, "0 results")
284284

@@ -292,20 +292,19 @@ def test_product_portfolio_detail_view_tab_vulnerability_queryset(self):
292292
self.assertEqual(4, product1.packages.vulnerable().count())
293293

294294
url = product1.get_url("tab_vulnerabilities")
295-
with self.assertMaxQueries(12):
295+
with self.assertMaxQueries(15):
296296
response = self.client.get(url)
297297
self.assertContains(response, "4 results")
298298

299299
def test_product_portfolio_tab_vulnerability_view_filters(self):
300300
self.client.login(username="nexb_user", password="secret")
301301
url = self.product1.get_url("tab_vulnerabilities")
302302
response = self.client.get(url)
303-
self.assertContains(response, "?vulnerabilities-weighted_risk_score=#vulnerabilities")
304-
self.assertContains(response, "?vulnerabilities-sort=weighted_risk_score#vulnerabilities")
305-
response = self.client.get(
306-
url + "?vulnerabilities-sort=weighted_risk_score#vulnerabilities"
303+
self.assertContains(response, "?vulnerabilities-triage_action=#vulnerabilities")
304+
self.assertContains(response, "?vulnerabilities-triage_action=upgrade#vulnerabilities")
305+
self.assertContains(
306+
response, "?vulnerabilities-vulnerability_analyses__state=#vulnerabilities"
307307
)
308-
self.assertContains(response, "?vulnerabilities-sort=-weighted_risk_score#vulnerabilities")
309308

310309
def test_product_portfolio_tab_vulnerability_view_packages_row_rendering(self):
311310
self.client.login(username="nexb_user", password="secret")
@@ -320,11 +319,9 @@ def test_product_portfolio_tab_vulnerability_view_packages_row_rendering(self):
320319
url = product1.get_url("tab_vulnerabilities")
321320
response = self.client.get(url)
322321
expected = f"""
323-
<td rowspan="2">
324-
<strong>
325-
<a href="{p1.get_absolute_url()}#vulnerabilities" target="_blank">{p1}</a>
326-
</strong>
327-
</td>
322+
<strong>
323+
<a href="{p1.get_absolute_url()}#vulnerabilities" target="_blank">{p1}</a>
324+
</strong>
328325
"""
329326
self.assertContains(response, expected, html=True)
330327

@@ -360,7 +357,7 @@ def test_product_portfolio_tab_vulnerability_view_queries(self):
360357
make_vulnerability_analysis(product_package2, vulnerability2)
361358

362359
url = product1.get_url("tab_vulnerabilities")
363-
with self.assertMaxQueries(12):
360+
with self.assertMaxQueries(15):
364361
self.client.get(url)
365362

366363
def test_product_portfolio_tab_vulnerability_risk_threshold(self):

product_portfolio/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from product_portfolio.views import import_from_scan_view
4747
from product_portfolio.views import import_packages_from_scancodeio_view
4848
from product_portfolio.views import improve_packages_from_purldb_view
49+
from product_portfolio.views import manage_triage_rulesets_view
4950
from product_portfolio.views import scan_all_packages_view
5051
from product_portfolio.views import scancodeio_project_download_input_view
5152
from product_portfolio.views import scancodeio_project_status_view
@@ -131,6 +132,7 @@ def product_path(path_segment, view):
131132
*product_path("vulnerability_analysis_form", vulnerability_analysis_form_view),
132133
*product_path("scan_all_packages", scan_all_packages_view),
133134
*product_path("evaluate_policy_rules", evaluate_policy_rules_view),
135+
*product_path("manage_triage_rulesets", manage_triage_rulesets_view),
134136
*product_path("improve_packages_from_purldb", improve_packages_from_purldb_view),
135137
*product_path("about_files", ProductSendAboutFilesView.as_view()),
136138
*product_path("export_spdx", ProductExportSPDXDocumentView.as_view()),

product_portfolio/views.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,11 @@
150150
from vulnerabilities.models import Vulnerability
151151
from vulnerabilities.models import VulnerabilityAnalysis
152152
from vulnerabilities.models import get_risk_level
153+
from vulnerabilities.triage.engine import evaluate_ruleset
154+
from vulnerabilities.triage.models import ProductTriageRuleset
153155
from vulnerabilities.triage.models import TriageAction
154156
from vulnerabilities.triage.models import TriageRecord
157+
from vulnerabilities.triage.models import TriageRuleset
155158

156159
TRIAGE_ACTION_STYLES = {
157160
"upgrade": ("bg-danger-subtle text-danger-emphasis", "fa-arrow-circle-up"),
@@ -2129,6 +2132,58 @@ def evaluate_policy_rules_view(request, dataspace, name, version=""):
21292132
return HttpResponse(headers={"HX-Refresh": "true"})
21302133

21312134

2135+
@login_required
2136+
def manage_triage_rulesets_view(request, dataspace, name, version=""):
2137+
guarded_qs = Product.objects.get_queryset(request.user, perms="change_product")
2138+
product = get_object_or_404(
2139+
guarded_qs,
2140+
name=unquote_plus(name),
2141+
version=unquote_plus(version),
2142+
dataspace__name=dataspace,
2143+
)
2144+
available_rulesets = list(
2145+
TriageRuleset.objects.filter(dataspace=product.dataspace, enabled=True).order_by(
2146+
"-precedence", "name"
2147+
)
2148+
)
2149+
2150+
if request.method == "POST":
2151+
submitted_uuids = set(request.POST.getlist("ruleset_uuids"))
2152+
current_assignments = {
2153+
str(ptr.ruleset.uuid): ptr
2154+
for ptr in ProductTriageRuleset.objects.filter(product=product).select_related(
2155+
"ruleset"
2156+
)
2157+
}
2158+
for ruleset in available_rulesets:
2159+
ruleset_uuid = str(ruleset.uuid)
2160+
if ruleset_uuid in submitted_uuids and ruleset_uuid not in current_assignments:
2161+
ProductTriageRuleset.objects.create(
2162+
product=product,
2163+
ruleset=ruleset,
2164+
dataspace=product.dataspace,
2165+
)
2166+
evaluate_ruleset(ruleset=ruleset, product=product)
2167+
for ruleset_uuid, assignment in current_assignments.items():
2168+
if ruleset_uuid not in submitted_uuids:
2169+
assignment.delete()
2170+
return JsonResponse({"success": True})
2171+
2172+
assigned_ruleset_ids = set(product.product_triage_rulesets.values_list("ruleset_id", flat=True))
2173+
action_labels = dict(TriageAction.choices)
2174+
for ruleset in available_rulesets:
2175+
ruleset.action_label = action_labels.get(ruleset.action, ruleset.action)
2176+
2177+
return render(
2178+
request,
2179+
"product_portfolio/modals/manage_triage_rulesets_form.html",
2180+
{
2181+
"available_rulesets": available_rulesets,
2182+
"assigned_ruleset_ids": assigned_ruleset_ids,
2183+
},
2184+
)
2185+
2186+
21322187
@login_required
21332188
def import_from_scan_view(request, dataspace, name, version=""):
21342189
"""

vulnerabilities/triage/admin.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,3 @@ def get_fieldsets(self, request, obj=None):
100100
)
101101
)
102102
return base_fieldsets + rule_fieldsets
103-
104-

vulnerabilities/triage/models.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,7 @@ def primary_actions(self):
9393
self.model.objects.filter(
9494
product_package=OuterRef("product_package"),
9595
ruleset__enabled=True,
96-
ruleset__product_triage_rulesets__product=OuterRef(
97-
"product_package__product"
98-
),
96+
ruleset__product_triage_rulesets__product=OuterRef("product_package__product"),
9997
)
10098
.order_by("-ruleset__precedence", "ruleset__name")
10199
.values("ruleset_id")[:1]

0 commit comments

Comments
 (0)