Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 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
46ddb00
refactor to use the advisory as the base for rules
tdruez Aug 11, 2026
2000f51
fix and improve vuln filtering
tdruez Aug 11, 2026
cdbae75
add a cron job for triage evalution
tdruez Aug 11, 2026
f0e6703
make precedence unique
tdruez Aug 11, 2026
debe83e
rework implementation
tdruez Aug 11, 2026
9f9d227
rework the rules and rulesets
tdruez Aug 12, 2026
bff5508
add a --reset opt
tdruez Aug 12, 2026
af73ae9
plug all the evaluation signals
tdruez Aug 12, 2026
36dbc0c
dynhamic display of the recommandation column
tdruez Aug 12, 2026
5eee378
add analisys preset system
tdruez Aug 12, 2026
ce2d413
bug fixes
tdruez Aug 12, 2026
f50d8b5
add request to the triage system
tdruez Aug 13, 2026
30fce91
add new fields in admin
tdruez Aug 13, 2026
dff860f
add new fields in form
tdruez Aug 13, 2026
2df2f7e
display the request status and link in the recommandation cell
tdruez Aug 13, 2026
c92b446
open a request for each TriageRecord when request_template is set
tdruez Aug 13, 2026
ca67643
move the validation on the mixin
tdruez Aug 13, 2026
97bb661
refinements
tdruez Aug 13, 2026
5a2a09c
add ability to set a analysis preset
tdruez Aug 13, 2026
ff79d75
add unit test coverage
tdruez Aug 14, 2026
112037b
add tooltip on the apply present dropdown link
tdruez Aug 14, 2026
865df9e
add missing unit test for full coverage
tdruez Aug 14, 2026
f3a5e2f
do not delete the link to requests
tdruez Aug 14, 2026
a9d0bb7
rename action as recommended_action
tdruez Aug 14, 2026
373a1ab
display analysis_preset in modal
tdruez Aug 14, 2026
1558c41
rework the analysis rendering
tdruez Aug 17, 2026
af8df34
rework the action buttons
tdruez Aug 17, 2026
78c897d
rework the vulnerability data popover
tdruez Aug 17, 2026
bc2c3c3
refine UI
tdruez Aug 17, 2026
95fa391
fix created and modified logic
tdruez Aug 17, 2026
150218e
fix CSS
tdruez Aug 17, 2026
5471f7c
refine UI
tdruez Aug 17, 2026
69a9a5f
fix test
tdruez Aug 17, 2026
515c710
fix filtering
tdruez Aug 17, 2026
f364104
handle the validation error in admin
tdruez Aug 17, 2026
c67b17f
add link to manage rules
tdruez Aug 17, 2026
97f050a
adjust default timeout
tdruez Aug 17, 2026
f91d72c
rework part of the code
tdruez Aug 17, 2026
034b5ad
fix underlying bugs
tdruez Aug 17, 2026
463ccd2
move evaluation in a signal
tdruez Aug 18, 2026
1515fd5
refactor signal
tdruez Aug 18, 2026
85ebf07
refine signal
tdruez Aug 18, 2026
f716f9a
merge signals
tdruez Aug 18, 2026
3328c73
deactivate signal in the context of imports
tdruez Aug 18, 2026
ee6bdb4
move signal in ad,in and view
tdruez Aug 18, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<div>
{% if vulnerability.aliases %}
<div class='mb-2'>
<strong>Aliases</strong>
<div>
{% for alias in vulnerability.aliases %}
<div>
{% if alias|slice:':3' == 'CVE' %}
<a href='https://nvd.nist.gov/vuln/detail/{{ alias }}' target='_blank'>{{ alias }}</a>
{% elif alias|slice:':4' == 'GHSA' %}
<a href='https://github.com/advisories/{{ alias }}' target='_blank'>{{ alias }}</a>
{% elif alias|slice:':3' == 'NPM' %}
<a href='https://github.com/nodejs/security-wg/blob/main/vuln/npm/{{ alias|slice:"4:" }}.json' target='_blank'>{{ alias }}</a>
{% else %}
{{ alias }}
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if vulnerability.summary %}
<div class='mb-2'>
<strong>Summary</strong>
<div>{{ vulnerability.summary|linebreaksbr }}</div>
</div>
{% endif %}
{% if vulnerability.exploitability %}
<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>
{% endif %}
</div>
2 changes: 2 additions & 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 Expand Up @@ -478,6 +479,7 @@ def gettext_noop(s):
hourly = "0 * * * *"
DEJACODE_VULNERABILITIES_CRON = env.str("DEJACODE_VULNERABILITIES_CRON", default=daily_at_3am)
DEJACODE_POLICY_RULES_CRON = env.str("DEJACODE_POLICY_RULES_CRON", default=hourly)
DEJACODE_VULNERABILITY_TRIAGE_CRON = env.str("DEJACODE_VULNERABILITY_TRIAGE_CRON", default=hourly)


def enable_rq_eager_mode():
Expand Down
22 changes: 18 additions & 4 deletions dejacode/static/css/dejacode_bootstrap.css
Original file line number Diff line number Diff line change
Expand Up @@ -414,10 +414,17 @@ table.vulnerabilities-table .column-summary {

/* -- Vulnerability tab -- */
#tab_vulnerabilities .column-advisory_uid {
width: 230px;
width: 240px;
}
#tab_vulnerabilities .column-affected_packages {
min-width: 250px;
min-width: 300px;
width: 300px;
}
#tab_vulnerabilities .column-triage_action {
min-width: 165px;
}
#tab_vulnerabilities .column-action {
width: 30px;
}
#tab_vulnerabilities .column-exploitability {
width: 140px;
Expand All @@ -433,13 +440,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 Expand Up @@ -1072,3 +1079,10 @@ select.bootstrap-select-filter {
position: absolute;
z-index: 1;
}
.popover.wide-popover {
--bs-popover-max-width: 420px;
max-width: 420px;
}
.stretch-cell {
height: 1px;
}
15 changes: 12 additions & 3 deletions dje/cron_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,28 @@

from dje.tasks import update_vulnerabilities
from policy.tasks import evaluate_all_products_rules_task
from vulnerabilities.triage.tasks import evaluate_all_products_vulnerability_triage_task

two_hour = 7200
two_hours = 7200
ten_minutes = 600

cron.register(
func=update_vulnerabilities,
queue_name="default",
cron=settings.DEJACODE_VULNERABILITIES_CRON, # Daily at 3am by default
job_timeout=two_hour,
job_timeout=two_hours,
)

cron.register(
func=evaluate_all_products_rules_task,
queue_name="default",
cron=settings.DEJACODE_POLICY_RULES_CRON, # Hourly by default
job_timeout=two_hour,
job_timeout=two_hours,
)

cron.register(
func=evaluate_all_products_vulnerability_triage_task,
queue_name="default",
cron=settings.DEJACODE_VULNERABILITY_TRIAGE_CRON, # Hourly by default
job_timeout=ten_minutes,
)
4 changes: 4 additions & 0 deletions dje/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ def scancodeio_submit_scan(uris, user_uuid, dataspace_uuid):
def update_vulnerabilities():
"""Fetch vulnerabilities for all Dataspaces that enable vulnerablecodedb access."""
from vulnerabilities.fetch import fetch_from_vulnerablecode
from vulnerabilities.triage.tasks import evaluate_all_products_vulnerability_triage_task

logger.info("Entering update_vulnerabilities task")
Dataspace = apps.get_model("dje", "Dataspace")
Expand All @@ -155,3 +156,6 @@ def update_vulnerabilities():
timeout=60,
log_func=logger.debug,
)

logger.info("Vulnerability fetch complete. Enqueuing triage evaluation.")
evaluate_all_products_vulnerability_triage_task.delay()
3 changes: 3 additions & 0 deletions dje/templates/tabs/pagination.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
{% endif %}
</div>
</li>
{% if extra_nav_item_template %}
{% include extra_nav_item_template %}
{% endif %}
</ul>
<div class="mt-1">
{% include 'includes/filters_breadcrumbs.html' with filterset=filterset fragment=tab_id only %}
Expand Down
2 changes: 1 addition & 1 deletion dje/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def test_tabs_permission_formset_load_perms(self):

def test_copy_defaults_form_get_all_dataspaced_models(self):
dataspaced_models = CopyDefaultsForm.get_all_dataspaced_models()
self.assertEqual(10, len(dataspaced_models))
self.assertEqual(11, len(dataspaced_models))
self.assertEqual(7, len(dataspaced_models.get("Component Catalog")))
self.assertIn("Subcomponent", str(dataspaced_models.get("Component Catalog")))

Expand Down
12 changes: 3 additions & 9 deletions policy/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,9 @@ def evaluate_product_rules_on_product_save(sender, instance, **kwargs):
evaluate_product_rules_task.delay(product_uuid=instance.uuid)


@receiver(post_save, sender="product_portfolio.ProductPackage")
def evaluate_product_rules_on_productpackage_save(sender, instance, **kwargs):
"""Queue a policy rule evaluation whenever a package is added or updated in a product."""
evaluate_product_rules_task.delay(product_uuid=instance.product.uuid)


@receiver(post_delete, sender="product_portfolio.ProductPackage")
def evaluate_product_rules_on_productpackage_delete(sender, instance, **kwargs):
"""Queue a policy rule evaluation whenever a package is removed from a product."""
@receiver([post_save, post_delete], sender="product_portfolio.ProductPackage")
def evaluate_product_rules_on_productpackage_change(sender, instance, **kwargs):
"""Queue a policy rule evaluation whenever a package is added, updated, or removed."""
evaluate_product_rules_task.delay(product_uuid=instance.product.uuid)


Expand Down
5 changes: 4 additions & 1 deletion product_portfolio/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@
from product_portfolio.models import ProductRelationStatus
from product_portfolio.models import ProductStatus
from reporting.filters import ReportingQueryListFilter
from vulnerabilities.triage.engine import reevaluate_product_rulesets
from vulnerabilities.triage.models import ProductTriageRuleset


@admin.register(ProductStatus, site=dejacode_site)
Expand Down Expand Up @@ -240,12 +242,13 @@ def save_model(self, request, obj, form, change):
if "_saveasnew" in request.POST:
old_product_id = request.resolver_match.kwargs.get("object_id")
old_product = self.get_object(request, old_product_id)
for model_class in [ProductComponent, ProductPackage]:
for model_class in [ProductComponent, ProductPackage, ProductTriageRuleset]:
for relationship in model_class.objects.filter(product=old_product):
relationship.id = None
relationship.uuid = uuid.uuid4()
relationship.product = obj
relationship.save()
reevaluate_product_rulesets(obj)

def get_obj_perms_user_select_form(self, request):
"""
Expand Down
20 changes: 20 additions & 0 deletions product_portfolio/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
from vulnerabilities.models import RISK_SCORE_RANGES
from vulnerabilities.models import Vulnerability
from vulnerabilities.models import VulnerabilityAnalysisMixin
from vulnerabilities.triage.models import TriageAction
from vulnerabilities.triage.models import TriageRecord


class HasComplianceIssueFilter(django_filters.BooleanFilter):
Expand Down Expand Up @@ -366,6 +368,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 +422,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 +447,17 @@ class Meta:
"exploitability",
]

@staticmethod
def filter_triage_action(queryset, name, value):
if not value:
return queryset
primary_triage = TriageRecord.objects.highest_precedence().filter(
product=OuterRef("product"),
vulnerability__affected_packages__productpackages=OuterRef("pk"),
recommended_action=value,
)
return queryset.filter(Exists(primary_triage)).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
51 changes: 49 additions & 2 deletions product_portfolio/importers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from django.db import transaction
from django.db.models import ObjectDoesNotExist
from django.db.models import Q
from django.db.models.signals import post_delete
from django.db.models.signals import post_save
from django.utils.translation import gettext_lazy as _

from license_expression import Licensing
Expand All @@ -40,6 +42,8 @@
from dje.models import Dataspace
from dje.utils import get_help_text
from dje.utils import is_uuid4
from policy.signals import evaluate_product_rules_on_productpackage_change
from policy.tasks import evaluate_product_rules_task
from product_portfolio.forms import ProductComponentLicenseExpressionFormMixin
from product_portfolio.models import CodebaseResource
from product_portfolio.models import CodebaseResourceUsage
Expand All @@ -50,6 +54,8 @@
from product_portfolio.models import ProductPackage
from product_portfolio.models import ProductRelationStatus
from product_portfolio.models import ScanCodeProject
from vulnerabilities.triage.signals import reevaluate_on_product_package_change
from vulnerabilities.triage.tasks import reevaluate_product_triage_rulesets_task

logger = logging.getLogger(__name__)

Expand All @@ -62,6 +68,35 @@ def log_elapsed(label):
logger.info(f"{label}: {time.perf_counter() - start:.1f}s")


@contextmanager
def paused_product_package_reevaluation():
"""
Pause the policy and triage re-evaluation signals triggered by ProductPackage changes,
for the duration of a bulk import. Call `reevaluate_products()` once the import completes
to evaluate each affected product exactly once, instead of once per imported row.
"""
receivers = [
evaluate_product_rules_on_productpackage_change,
reevaluate_on_product_package_change,
]
for receiver in receivers:
post_save.disconnect(receiver, sender=ProductPackage)
post_delete.disconnect(receiver, sender=ProductPackage)
try:
yield
finally:
for receiver in receivers:
post_save.connect(receiver, sender=ProductPackage)
post_delete.connect(receiver, sender=ProductPackage)


def reevaluate_products(products):
"""Queue the policy and triage re-evaluation once for each of the given products."""
for product in products:
evaluate_product_rules_task.delay(product_uuid=product.uuid)
reevaluate_product_triage_rulesets_task.delay(product_uuid=product.uuid)


class CleanProductMixin(ComponentRelatedFieldImportMixin):
def clean_product(self):
queryset = Product.objects.get_queryset(self.user)
Expand Down Expand Up @@ -230,6 +265,14 @@ def clean_package(self):
class ProductPackageImporter(BaseImporter):
model_form = ProductPackageImportForm

def save_all(self):
with paused_product_package_reevaluation():
super().save_all()

touched_product_packages = self.results["added"] + self.results["modified"]
products = {product_package.product for product_package in touched_product_packages}
reevaluate_products(products)


class CodebaseResourceImportForm(CleanProductMixin, BaseImportModelForm):
product = forms.CharField(
Expand Down Expand Up @@ -434,7 +477,9 @@ def save(self):
self.create_scancode_project()
self.load_data_from_file()
self.validate_headers()
self.import_packages()
with paused_product_package_reevaluation():
self.import_packages()
reevaluate_products([self.product])
if self.create_codebase_resources:
self.import_codebase_resources()
self.update_scancode_project()
Expand Down Expand Up @@ -720,7 +765,9 @@ def save(self):
self.dependencies = scancodeio.fetch_project_dependencies(self.project_uuid)

with log_elapsed("import_packages"):
self.import_packages()
with paused_product_package_reevaluation():
self.import_packages()
reevaluate_products([self.product])

if self.create_dependencies:
with log_elapsed("import_dependencies"):
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,6 @@
{% load i18n %}
<li class="nav-item ms-auto">
<a href="#" class="small text-body-secondary" data-bs-toggle="modal" data-bs-target="#manage-triage-rulesets-modal" data-manage-url="{{ product.get_manage_triage_rulesets_url }}">
<i class="fas fa-sliders me-1"></i>{% trans "Manage triage rules" %}
</a>
</li>
Loading
Loading