Skip to content

Commit 5eee378

Browse files
committed
add analisys preset system
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 36dbc0c commit 5eee378

10 files changed

Lines changed: 375 additions & 11 deletions

File tree

vulnerabilities/forms.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def helper(self):
9797
return helper
9898

9999
def save(self, *args, **kwargs):
100+
self.instance.applied_by_preset = None
100101
instance = super().save(*args, **kwargs)
101102

102103
if products := self.cleaned_data.get("propagate_to_products"):
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 6.0.6 on 2026-08-12 06:37
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('vulnerabilities', '0010_api_v3_remove_old_fields'),
11+
('vulnerabilities_triage', '0002_analysispreset_triageruleset_analysis_preset'),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='vulnerabilityanalysis',
17+
name='applied_by_preset',
18+
field=models.ForeignKey(blank=True, help_text='The analysis preset that automatically created this analysis. Cleared when a user edits the analysis manually.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applied_analyses', to='vulnerabilities_triage.analysispreset'),
19+
),
20+
]

vulnerabilities/models.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,8 @@ def as_cyclonedx(self, affected_instances, analysis=None):
274274
)
275275

276276

277-
class VulnerabilityAnalysisMixin(models.Model):
278-
"""Aligned with the cyclonedx.model.vulnerability.VulnerabilityAnalysis"""
277+
class VulnerabilityAnalysisContentMixin(models.Model):
278+
"""Core analysis content fields, shared with AnalysisPreset. CycloneDX-aligned."""
279279

280280
# cyclonedx.model.impact_analysis.ImpactAnalysisState
281281
class State(models.TextChoices):
@@ -344,6 +344,14 @@ class Response(models.TextChoices):
344344
"details on why the component or service is not impacted by this vulnerability."
345345
),
346346
)
347+
348+
class Meta:
349+
abstract = True
350+
351+
352+
class VulnerabilityAnalysisMixin(VulnerabilityAnalysisContentMixin):
353+
"""Aligned with the cyclonedx.model.vulnerability.VulnerabilityAnalysis"""
354+
347355
first_issued = models.DateTimeField(
348356
auto_now_add=True,
349357
help_text=_("The date and time (timestamp) when the analysis was first issued."),
@@ -546,6 +554,17 @@ class VulnerabilityAnalysis(
546554
"product package."
547555
),
548556
)
557+
applied_by_preset = models.ForeignKey(
558+
to="vulnerabilities_triage.AnalysisPreset",
559+
on_delete=models.SET_NULL,
560+
null=True,
561+
blank=True,
562+
related_name="applied_analyses",
563+
help_text=_(
564+
"The analysis preset that automatically created this analysis."
565+
" Cleared when a user edits the analysis manually."
566+
),
567+
)
549568

550569
objects = DataspacedManager.from_queryset(ProductSecuredQuerySet)()
551570

vulnerabilities/triage/admin.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,61 @@
1212

1313
from dje.admin import DataspacedAdmin
1414
from dje.admin import dejacode_site
15+
from vulnerabilities.triage.forms import AnalysisPresetForm
1516
from vulnerabilities.triage.forms import TriageRulesetForm
17+
from vulnerabilities.triage.models import AnalysisPreset
1618
from vulnerabilities.triage.models import TriageAction
1719
from vulnerabilities.triage.models import TriageRuleset
1820
from vulnerabilities.triage.rules import RULE_REGISTRY
1921

2022

23+
@admin.register(AnalysisPreset, site=dejacode_site)
24+
class AnalysisPresetAdmin(DataspacedAdmin):
25+
form = AnalysisPresetForm
26+
short_description = (
27+
"An Analysis Preset defines default vulnerability analysis values that the triage"
28+
" engine applies automatically when an assigned ruleset fires."
29+
)
30+
long_description = (
31+
"Only non-blank preset fields are applied. Analyses already modified by a human"
32+
" are never overwritten. When a user edits an auto-applied analysis, the preset"
33+
" link is cleared and the analysis becomes human-owned."
34+
)
35+
list_display = [
36+
"name",
37+
"state",
38+
"justification",
39+
"get_responses",
40+
"is_reachable",
41+
"description",
42+
"get_dataspace",
43+
]
44+
search_fields = ["name"]
45+
fieldsets = [
46+
(
47+
None,
48+
{"fields": ["name", "description"]},
49+
),
50+
(
51+
"Analysis defaults",
52+
{
53+
"fields": ["state", "justification", "responses", "detail", "is_reachable"],
54+
"description": (
55+
"Leave a field blank to leave it unchanged on the analysis."
56+
" At least one field should be set."
57+
),
58+
},
59+
),
60+
]
61+
62+
@admin.display(description="Responses")
63+
def get_responses(self, obj):
64+
if not obj.responses:
65+
return ""
66+
labels = dict(AnalysisPreset.Response.choices)
67+
return ", ".join(labels.get(response, response) for response in obj.responses)
68+
69+
2170
@admin.register(TriageRuleset, site=dejacode_site)
2271
class TriageRulesetAdmin(DataspacedAdmin):
2372
short_description = (
@@ -39,6 +88,7 @@ class TriageRulesetAdmin(DataspacedAdmin):
3988
"get_action_label",
4089
"precedence",
4190
"get_enabled_rules",
91+
"analysis_preset",
4292
"description",
4393
"enabled",
4494
"get_dataspace",
@@ -74,14 +124,30 @@ def get_changes_details(self, form):
74124
return super().get_changes_details(form)
75125

76126
def get_form(self, request, obj=None, change=False, **kwargs):
77-
kwargs["fields"] = ["name", "description", "action", "precedence", "enabled"]
127+
kwargs["fields"] = [
128+
"name",
129+
"description",
130+
"action",
131+
"precedence",
132+
"enabled",
133+
"analysis_preset",
134+
]
78135
return super().get_form(request, obj, change=change, **kwargs)
79136

80137
def get_fieldsets(self, request, obj=None):
81138
base_fieldsets = [
82139
(
83140
None,
84-
{"fields": ["name", "description", "action", "precedence", "enabled"]},
141+
{
142+
"fields": [
143+
"name",
144+
"description",
145+
"action",
146+
"precedence",
147+
"enabled",
148+
"analysis_preset",
149+
]
150+
},
85151
),
86152
]
87153
rule_fieldsets = []

vulnerabilities/triage/engine.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
# See https://aboutcode.org for more information about AboutCode FOSS projects.
77
#
88

9+
from django.apps import apps
10+
911
from vulnerabilities.triage.models import TriageRecord
1012
from vulnerabilities.triage.rules import RULE_REGISTRY
1113

@@ -37,10 +39,75 @@ def collect_matches(ruleset, product):
3739
return matched_rules_per_vulnerability_id
3840

3941

42+
def apply_preset_for_vulnerabilities(preset, product, vulnerability_ids):
43+
"""
44+
For each (product_package, vulnerability) pair in the product, create or update
45+
a VulnerabilityAnalysis using preset values.
46+
47+
Skips any analysis already modified by a human (applied_by_preset is null on an
48+
existing record). Only analyses that were auto-created (applied_by_preset is set)
49+
or brand-new are touched.
50+
"""
51+
VulnerabilityAnalysis = apps.get_model("vulnerabilities", "VulnerabilityAnalysis")
52+
ProductPackage = apps.get_model("product_portfolio", "ProductPackage")
53+
54+
vulnerability_ids = list(vulnerability_ids)
55+
56+
# One query: exact (product_package_id, vulnerability_id) pairs to process.
57+
# Filtering by __id__in on the M2M restricts the JOIN rows to the matching
58+
# vulnerabilities, so values_list returns only the pairs we want.
59+
pp_vuln_pairs = set(
60+
ProductPackage.objects.filter(
61+
product=product,
62+
package__affected_by_vulnerabilities__id__in=vulnerability_ids,
63+
)
64+
.values_list("id", "package__affected_by_vulnerabilities__id")
65+
.distinct()
66+
)
67+
68+
if not pp_vuln_pairs:
69+
return
70+
71+
pp_ids = {pp_id for pp_id, _ in pp_vuln_pairs}
72+
73+
# One query: all existing analyses for this product_package / vulnerability set
74+
existing_analyses = {
75+
(analysis.product_package_id, analysis.vulnerability_id): analysis
76+
for analysis in VulnerabilityAnalysis.objects.filter(
77+
product_package_id__in=pp_ids,
78+
vulnerability_id__in=vulnerability_ids,
79+
)
80+
}
81+
82+
# One query: product_package instances needed to construct new analyses
83+
product_packages_by_id = {pp.pk: pp for pp in ProductPackage.objects.filter(pk__in=pp_ids)}
84+
85+
for product_package_id, vulnerability_id in pp_vuln_pairs:
86+
existing = existing_analyses.get((product_package_id, vulnerability_id))
87+
88+
if existing is not None and existing.applied_by_preset_id is None:
89+
continue # Human-owned analysis -- never overwrite
90+
91+
if existing is None:
92+
product_package = product_packages_by_id[product_package_id]
93+
analysis = VulnerabilityAnalysis(
94+
product_package=product_package,
95+
vulnerability_id=vulnerability_id,
96+
dataspace_id=product.dataspace_id,
97+
)
98+
else:
99+
analysis = existing
100+
101+
preset.apply_to_analysis(analysis)
102+
analysis.applied_by_preset = preset
103+
analysis.save()
104+
105+
40106
def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id):
41107
"""
42108
Create or update one TriageRecord per matching vulnerability, then
43109
delete records for vulnerabilities that no longer match any rule in the ruleset.
110+
Applies the ruleset's analysis_preset when configured.
44111
"""
45112
for vulnerability_id, matched_rules in matched_rules_per_vulnerability_id.items():
46113
TriageRecord.objects.update_or_create(
@@ -59,6 +126,13 @@ def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id):
59126
product=product,
60127
).exclude(vulnerability_id__in=matched_rules_per_vulnerability_id.keys()).delete()
61128

129+
if ruleset.analysis_preset_id and matched_rules_per_vulnerability_id:
130+
apply_preset_for_vulnerabilities(
131+
preset=ruleset.analysis_preset,
132+
product=product,
133+
vulnerability_ids=list(matched_rules_per_vulnerability_id.keys()),
134+
)
135+
62136

63137
def evaluate_ruleset(ruleset, product):
64138
"""Evaluate a TriageRuleset against a product and persist the results."""

vulnerabilities/triage/forms.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,33 @@
99
from django import forms
1010

1111
from dje.forms import DataspacedAdminForm
12+
from vulnerabilities.models import VulnerabilityAnalysisContentMixin
13+
from vulnerabilities.triage.models import AnalysisPreset
1214
from vulnerabilities.triage.models import TriageAction
1315
from vulnerabilities.triage.models import TriageRuleset
1416
from vulnerabilities.triage.rules import RULE_REGISTRY
1517

1618

19+
class AnalysisPresetForm(DataspacedAdminForm):
20+
responses = forms.MultipleChoiceField(
21+
choices=VulnerabilityAnalysisContentMixin.Response.choices,
22+
widget=forms.CheckboxSelectMultiple,
23+
required=False,
24+
)
25+
26+
class Meta:
27+
model = AnalysisPreset
28+
fields = [
29+
"name",
30+
"description",
31+
"state",
32+
"justification",
33+
"responses",
34+
"detail",
35+
"is_reachable",
36+
]
37+
38+
1739
class TriageRulesetForm(DataspacedAdminForm):
1840
action = forms.ChoiceField(
1941
choices=[("", "---------")] + list(TriageAction.choices),
@@ -22,7 +44,7 @@ class TriageRulesetForm(DataspacedAdminForm):
2244

2345
class Meta:
2446
model = TriageRuleset
25-
fields = ["name", "description", "action", "precedence", "enabled"]
47+
fields = ["name", "description", "action", "precedence", "enabled", "analysis_preset"]
2648

2749
def __init__(self, *args, **kwargs):
2850
super().__init__(*args, **kwargs)

0 commit comments

Comments
 (0)