Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
a7d7929
add vulnerabilities triage module
tdruez Jul 30, 2026
b7e161f
add TriageRuleset model
tdruez Jul 30, 2026
20966b0
add 2 vulnrability triage rules
tdruez Aug 5, 2026
c1e524e
add verbose name
tdruez Aug 5, 2026
a84c095
add TriageRulesetAdmin to manage ruleset
tdruez Aug 5, 2026
7de1361
display enabled rules in change list
tdruez Aug 5, 2026
ac49367
simplify the rules implementation
tdruez Aug 5, 2026
7f4a02d
display rules on add form
tdruez Aug 5, 2026
c89ab04
add evaluate_ruleset engine
tdruez Aug 5, 2026
49b9207
add TriageDecision model
tdruez Aug 6, 2026
34d0076
refine list display
tdruez Aug 6, 2026
345118a
add management command to load ruleset data
tdruez Aug 6, 2026
691b369
add command to trigger triage evaluation
tdruez Aug 6, 2026
2465371
implement TriageDecisionAdmin
tdruez Aug 6, 2026
41121a3
add new triage rules
tdruez Aug 6, 2026
bf9d578
rename function
tdruez Aug 6, 2026
333bc72
refine admin rendering
tdruez Aug 6, 2026
ddd3c51
add parameter in evaluation
tdruez Aug 6, 2026
a4c788d
updat the reference ruleset
tdruez Aug 6, 2026
795c533
display description in admin
tdruez Aug 6, 2026
9833db1
refine default rules
tdruez Aug 6, 2026
0d64a0b
refine rule descriptions
tdruez Aug 6, 2026
113c40c
add parameter in form
tdruez Aug 6, 2026
9545c16
fix form bugs
tdruez Aug 6, 2026
47eafdf
refactor the qactions outside the model
tdruez Aug 6, 2026
82329bc
refactoring toward a ProductPackageTriage model
tdruez Aug 7, 2026
9bf8473
delete all package triage records when a ruleset is disabled
tdruez Aug 7, 2026
52cdc58
add primary_actions qs method
tdruez Aug 7, 2026
286482f
rename model to TriageRecord
tdruez Aug 7, 2026
e5994ad
refine admin ordering
tdruez Aug 7, 2026
293a6b4
add Triage tab in the product details view
tdruez Aug 7, 2026
364f8f8
progress on the triage tab
tdruez Aug 10, 2026
81f506b
refine tab header
tdruez Aug 10, 2026
427d965
refine default ordering
tdruez Aug 10, 2026
0f7ae37
add sort and filtering
tdruez Aug 10, 2026
cafae07
move triage content into the vulnerabilites tab
tdruez Aug 10, 2026
a565d82
remove TriageRecordAdmin
tdruez Aug 11, 2026
72b33d9
add ProductTriageRuleset relation
tdruez Aug 11, 2026
b309fb3
enable rulsets per products
tdruez Aug 11, 2026
162fcec
refine product rule management UI
tdruez Aug 11, 2026
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
1 change: 1 addition & 0 deletions dejacode/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ def gettext_noop(s):
"policy",
"notification",
"vulnerabilities",
"vulnerabilities.triage",
]

EXTRA_APPS = env.list("EXTRA_APPS", default=[])
Expand Down
4 changes: 2 additions & 2 deletions dejacode/static/css/dejacode_bootstrap.css
Original file line number Diff line number Diff line change
Expand Up @@ -433,13 +433,13 @@ table.vulnerabilities-table .column-summary {
width: 300px;
}
#tab_vulnerabilities .column-vulnerability_analyses__state {
min-width: 125px;
min-width: 100px;
}
#tab_vulnerabilities .column-vulnerability_analyses__justification {
min-width: 130px;
}
#tab_vulnerabilities .column-vulnerability_analyses__responses {
width: 185px;
min-width: 120px;
}
#tab_vulnerabilities .column-vulnerability_analyses__is_reachable {
width: 80px;
Expand Down
17 changes: 17 additions & 0 deletions product_portfolio/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from vulnerabilities.models import RISK_SCORE_RANGES
from vulnerabilities.models import Vulnerability
from vulnerabilities.models import VulnerabilityAnalysisMixin
from vulnerabilities.triage.models import TriageAction


class HasComplianceIssueFilter(django_filters.BooleanFilter):
Expand Down Expand Up @@ -366,6 +367,7 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet):
dropdown_fields = [
"is_modified",
"weighted_risk_score",
"triage_action",
"vulnerability_analyses__state",
"vulnerability_analyses__justification",
"responses",
Expand Down Expand Up @@ -419,6 +421,12 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet):
("unknown", _("Reachability not known")),
),
)
triage_action = django_filters.ChoiceFilter(
label=_("Triage action"),
choices=TriageAction.choices,
empty_label=_("All actions"),
method="filter_triage_action",
)
compliance_issues = HasComplianceIssueFilter(
field_name="package__usage_policy__compliance_alert",
distinct=True,
Expand All @@ -438,6 +446,15 @@ class Meta:
"exploitability",
]

@staticmethod
def filter_triage_action(queryset, name, value):
if not value:
return queryset
return queryset.filter(
triage_records__action=value,
triage_records__ruleset__enabled=True,
).distinct()

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.filters["vulnerability_analyses__state"].extra["null_label"] = "(No values)"
Expand Down
2 changes: 2 additions & 0 deletions product_portfolio/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from product_portfolio.models import ScanCodeProject
from product_portfolio.tasks import pull_project_data_from_scancodeio_task
from product_portfolio.tasks import scancodeio_submit_project_task
from vulnerabilities.triage.models import ProductTriageRuleset


class NameVersionValidationFormMixin:
Expand Down Expand Up @@ -106,6 +107,7 @@ class ProductForm(
ProductComponent,
ProductPackage,
CodebaseResource,
ProductTriageRuleset,
]

keywords = KeywordsField()
Expand Down
3 changes: 3 additions & 0 deletions product_portfolio/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@ def get_export_security_compliance_url(self):
def get_evaluate_policy_rules_url(self):
return self.get_url("evaluate_policy_rules")

def get_manage_triage_rulesets_url(self):
return self.get_url("manage_triage_rulesets")

@property
def cyclonedx_bom_ref(self):
return str(self.uuid)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{% load i18n %}
<form id="manage-triage-rulesets-form">
{% if available_rulesets %}
<p class="text-body-secondary small px-3 pt-3 mb-0">
{% trans "Select the rules to activate for this product. Saving triggers an evaluation of all active packages." %}
</p>
<div class="list-group list-group-flush mt-2">
{% for ruleset in available_rulesets %}
<label class="list-group-item d-flex gap-3 py-3" for="ruleset_{{ ruleset.uuid }}">
<input class="form-check-input flex-shrink-0 mt-1"
type="checkbox"
name="ruleset_uuids"
value="{{ ruleset.uuid }}"
id="ruleset_{{ ruleset.uuid }}"
{% if ruleset.id in assigned_ruleset_ids %}checked{% endif %}>
<span class="flex-grow-1">
<span class="d-flex justify-content-between align-items-baseline mb-1">
<strong>{{ ruleset.name }}</strong>
<span class="badge bg-secondary-subtle text-secondary-emphasis ms-2 flex-shrink-0">
{% trans "Precedence:" %} {{ ruleset.precedence }}
</span>
</span>
{% if ruleset.description %}
<span class="d-block small text-body-secondary mb-1">{{ ruleset.description }}</span>
{% endif %}
{% if ruleset.active_rules %}
<span class="d-flex align-items-center flex-wrap gap-1 mb-1">
<span class="small text-body-secondary me-1">{% trans "Conditions:" %}</span>
{% for rule in ruleset.active_rules %}
<span class="badge bg-secondary-subtle text-secondary-emphasis fw-normal">
{{ rule.label }}{% if rule.params_str %} ({{ rule.params_str }}){% endif %}
</span>
{% endfor %}
</span>
{% endif %}
<span class="d-flex align-items-center gap-2 mt-1">
<span class="small text-body-secondary">{% trans "Recommended action:" %}</span>
<span class="badge {{ ruleset.action_badge_class }} text-nowrap">
<i class="fas {{ ruleset.action_icon }} me-1"></i>{{ ruleset.action_label }}
</span>
</span>
</span>
</label>
{% endfor %}
</div>
{% else %}
<p class="text-body-secondary px-3 py-3 mb-0">
{% trans "No triage rules are available in this dataspace." %}
</p>
{% endif %}
</form>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% load i18n %}
<div id="manage-triage-rulesets-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Triage Rules" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div id="manage-triage-rulesets-body" class="modal-body p-0">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="button" id="submit-triage-rulesets" class="btn btn-primary">{% trans "Save" %}</button>
</div>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<i class="fas fa-tasks"></i> Manage
</button>
</span>
{% elif has_edit_productcomponent or has_edit_productpackage or has_change_codebaseresource_permission or has_add_productcomponent %}
{% elif has_edit_productcomponent or has_edit_productpackage or has_change_codebaseresource_permission or has_add_productcomponent or has_change_permission %}
<div class="dropdown btn-group">
<a class="btn btn-outline-dark dropdown-toggle" data-bs-toggle="dropdown" role="button" href="#"><i class="fas fa-tasks"></i> {% trans 'Manage' %}</a>
<div class="dropdown-menu dropdown-menu-end">
Expand All @@ -32,6 +32,16 @@
{% if purldb_enabled %}
<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>
{% endif %}
{% if has_change_permission and request.user.dataspace.enable_vulnerablecodedb_access %}
<hr class="dropdown-divider">
<div class="dropdown-header">{% trans "Vulnerabilities" %}</div>
<button class="dropdown-item" type="button"
data-bs-toggle="modal"
data-bs-target="#manage-triage-rulesets-modal"
data-manage-url="{{ product.get_manage_triage_rulesets_url }}">
<i class="fas fa-filter me-1"></i> {% trans "Triage Rules" %}
</button>
{% endif %}
</div>
</div>
{% endif %}
Expand Down Expand Up @@ -148,6 +158,9 @@
{% if request.user.dataspace.enable_vulnerablecodedb_access and product.vulnerability_count %}
{% include 'product_portfolio/modals/vulnerability_analysis_modal.html' %}
{% endif %}
{% if has_change_permission and request.user.dataspace.enable_vulnerablecodedb_access %}
{% include 'product_portfolio/modals/manage_triage_rulesets_modal.html' %}
{% endif %}
{% endblock %}

{% block messages-alert %}
Expand Down Expand Up @@ -281,6 +294,39 @@
</script>
{% endif %}

{% if has_change_permission and request.user.dataspace.enable_vulnerablecodedb_access %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const triageModal = document.getElementById("manage-triage-rulesets-modal");
if (!triageModal) return;

triageModal.addEventListener("show.bs.modal", function (event) {
const body = document.getElementById("manage-triage-rulesets-body");
body.innerHTML = "";
const manageUrl = event.relatedTarget.dataset.manageUrl;
document.getElementById("submit-triage-rulesets").dataset.manageUrl = manageUrl;
fetch(manageUrl)
.then(r => r.text())
.then(html => { body.innerHTML = html; })
.catch(() => { body.innerHTML = "Error loading content."; });
});

document.getElementById("submit-triage-rulesets").addEventListener("click", function () {
const manageUrl = this.dataset.manageUrl;
const form = document.getElementById("manage-triage-rulesets-form");
fetch(manageUrl, {
method: "POST",
headers: { "X-CSRFToken": csrftoken },
body: new FormData(form),
})
.then(r => r.json())
.then(data => { if (data.success) location.reload(); })
.catch(() => {});
});
});
</script>
{% endif %}

{% if request.user.dataspace.enable_vulnerablecodedb_access and product.vulnerability_count %}
<script>
$(document).ready(function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,36 @@
<strong>
<a href="{{ product_package.package.get_absolute_url }}#vulnerabilities" target="_blank">{{ product_package.package }}</a>
</strong>
{% if product_package.purpose %}
<div class="text-nowrap mt-1">
{{ product_package.purpose.label_with_icon }}
{% if product_package.purpose.exposure_factor %}
<div>
<i class="fas fa-arrow-alt-circle-right"></i>
Exposure factor: {{ product_package.purpose.exposure_factor }}
</div>
<div class="d-flex flex-wrap gap-2 mt-1">
{% with score=product_package.weighted_risk_score %}
{% if score %}
<span class="badge {% if score >= 8.0 %}bg-danger-subtle text-danger-emphasis{% elif score >= 6.0 %}bg-warning-subtle text-warning-emphasis{% elif score >= 3.0 %}bg-info-subtle text-info-emphasis{% else %}bg-secondary-subtle text-secondary-emphasis{% endif %}">
{% trans "Risk" %} {{ score }}
</span>
{% endif %}
<div>
{{ product_package.is_deployed|as_icon }}
{% if product_package.is_deployed %}Deployed{% else %}Not deployed{% endif %}
</div>
</div>
{% endif %}
{% endwith %}
{% if product_package.is_deployed %}
<span class="badge text-bg-light border">{% trans "Deployed" %}</span>
{% endif %}
{% if product_package.purpose %}
<span class="badge text-bg-light border">{{ product_package.purpose.label }}</span>
{% endif %}
</div>
</td>
<td rowspan="{{ product_package.package.vulnerability_count }}">
{% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=product_package.weighted_risk_score only %}
{% if product_package.triage_record %}
{% with record=product_package.triage_record %}
<span class="badge {{ record.action_badge_class }} d-block text-start">
<i class="fas {{ record.action_icon }} me-1"></i>{{ record.action_label }}
</span>
<div class="small text-body-secondary mt-1 ps-1">
<i class="fas fa-layer-group fa-xs me-1"></i>{{ record.ruleset.name }}
</div>
<div class="small text-body-tertiary mt-1 ps-1">
{% trans "Since:" %} {{ record.detected_date|date:"M j, Y" }}
</div>
{% endwith %}
{% endif %}
</td>
{% for vulnerability in product_package.package.affected_by_vulnerabilities.all %}
{% if not forloop.first %}<tr>{% endif %}
Expand All @@ -56,9 +68,19 @@
</span>
{% endif %}
</strong>
<div>
{% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=vulnerability.risk_score label="Risk:" only %}
{% include 'vulnerabilities/includes/exploitability.html' with instance=vulnerability only %}
<div class="d-flex flex-wrap gap-1 mt-1">
{% with score=vulnerability.risk_score %}
{% if score %}
<span class="badge {% if score >= 8.0 %}bg-danger-subtle text-danger-emphasis{% elif score >= 6.0 %}bg-warning-subtle text-warning-emphasis{% elif score >= 3.0 %}bg-info-subtle text-info-emphasis{% else %}bg-secondary-subtle text-secondary-emphasis{% endif %}">
{% trans "Risk:" %} {{ score }}
</span>
{% endif %}
{% endwith %}
{% if vulnerability.exploitability %}
<span class="badge {% if vulnerability.exploitability == 2.0 %}bg-danger-subtle text-danger-emphasis{% elif vulnerability.exploitability == 1.0 %}bg-warning-subtle text-warning-emphasis{% else %}bg-secondary-subtle text-secondary-emphasis{% endif %}">
{{ vulnerability.get_exploitability_display }}
</span>
{% endif %}
</div>
{% if vulnerability.aliases %}
<div class="mt-2">
Expand Down
23 changes: 10 additions & 13 deletions product_portfolio/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ def test_product_portfolio_detail_view_tab_vulnerability_queryset(self):
self.client.login(username="nexb_user", password="secret")
url = self.product1.get_url("tab_vulnerabilities")

with self.assertMaxQueries(9):
with self.assertMaxQueries(12):
response = self.client.get(url)
self.assertContains(response, "0 results")

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

url = product1.get_url("tab_vulnerabilities")
with self.assertMaxQueries(12):
with self.assertMaxQueries(15):
response = self.client.get(url)
self.assertContains(response, "4 results")

def test_product_portfolio_tab_vulnerability_view_filters(self):
self.client.login(username="nexb_user", password="secret")
url = self.product1.get_url("tab_vulnerabilities")
response = self.client.get(url)
self.assertContains(response, "?vulnerabilities-weighted_risk_score=#vulnerabilities")
self.assertContains(response, "?vulnerabilities-sort=weighted_risk_score#vulnerabilities")
response = self.client.get(
url + "?vulnerabilities-sort=weighted_risk_score#vulnerabilities"
self.assertContains(response, "?vulnerabilities-triage_action=#vulnerabilities")
self.assertContains(response, "?vulnerabilities-triage_action=upgrade#vulnerabilities")
self.assertContains(
response, "?vulnerabilities-vulnerability_analyses__state=#vulnerabilities"
)
self.assertContains(response, "?vulnerabilities-sort=-weighted_risk_score#vulnerabilities")

def test_product_portfolio_tab_vulnerability_view_packages_row_rendering(self):
self.client.login(username="nexb_user", password="secret")
Expand All @@ -320,11 +319,9 @@ def test_product_portfolio_tab_vulnerability_view_packages_row_rendering(self):
url = product1.get_url("tab_vulnerabilities")
response = self.client.get(url)
expected = f"""
<td rowspan="2">
<strong>
<a href="{p1.get_absolute_url()}#vulnerabilities" target="_blank">{p1}</a>
</strong>
</td>
<strong>
<a href="{p1.get_absolute_url()}#vulnerabilities" target="_blank">{p1}</a>
</strong>
"""
self.assertContains(response, expected, html=True)

Expand Down Expand Up @@ -360,7 +357,7 @@ def test_product_portfolio_tab_vulnerability_view_queries(self):
make_vulnerability_analysis(product_package2, vulnerability2)

url = product1.get_url("tab_vulnerabilities")
with self.assertMaxQueries(12):
with self.assertMaxQueries(15):
self.client.get(url)

def test_product_portfolio_tab_vulnerability_risk_threshold(self):
Expand Down
2 changes: 2 additions & 0 deletions product_portfolio/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from product_portfolio.views import import_from_scan_view
from product_portfolio.views import import_packages_from_scancodeio_view
from product_portfolio.views import improve_packages_from_purldb_view
from product_portfolio.views import manage_triage_rulesets_view
from product_portfolio.views import scan_all_packages_view
from product_portfolio.views import scancodeio_project_download_input_view
from product_portfolio.views import scancodeio_project_status_view
Expand Down Expand Up @@ -131,6 +132,7 @@ def product_path(path_segment, view):
*product_path("vulnerability_analysis_form", vulnerability_analysis_form_view),
*product_path("scan_all_packages", scan_all_packages_view),
*product_path("evaluate_policy_rules", evaluate_policy_rules_view),
*product_path("manage_triage_rulesets", manage_triage_rulesets_view),
*product_path("improve_packages_from_purldb", improve_packages_from_purldb_view),
*product_path("about_files", ProductSendAboutFilesView.as_view()),
*product_path("export_spdx", ProductExportSPDXDocumentView.as_view()),
Expand Down
Loading
Loading