Skip to content

Commit f3a5e2f

Browse files
committed
do not delete the link to requests
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 865df9e commit f3a5e2f

4 files changed

Lines changed: 165 additions & 10 deletions

File tree

vulnerabilities/triage/engine.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,9 @@ def sync_triage_records(ruleset, product, matched_rules_per_vulnerability_id, ap
176176
vulnerability_ids=stale_vulnerability_ids,
177177
)
178178

179-
stale_records_qs.delete()
179+
# Records with an open Request are kept so a later rematch reconnects to it (via the
180+
# update_conflicts upsert above) instead of opening a duplicate Request.
181+
stale_records_qs.filter(request__isnull=True).delete()
180182

181183
if apply_preset and ruleset.analysis_preset_id and matched_rules_per_vulnerability_id:
182184
apply_preset_for_vulnerabilities(

vulnerabilities/triage/signals.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ def reevaluate_product_rulesets(product, apply_preset=True):
3030
def reevaluate_or_delete_on_ruleset_save(sender, instance, created, **kwargs):
3131
"""Re-evaluate assigned products on config change; delete records when disabled."""
3232
if not instance.enabled:
33-
instance.triage_records.all().delete()
33+
# Records with an open Request are kept so re-enabling the ruleset reconnects to it
34+
# instead of opening a duplicate Request.
35+
instance.triage_records.filter(request__isnull=True).delete()
3436
return
3537

3638
if created:
@@ -43,16 +45,14 @@ def reevaluate_or_delete_on_ruleset_save(sender, instance, created, **kwargs):
4345
@receiver(post_delete, sender="vulnerabilities_triage.ProductTriageRuleset")
4446
def delete_triage_records_on_unassign(sender, instance, **kwargs):
4547
"""Delete triage records and associated preset analyses when a ruleset is de-assigned."""
46-
stale_vulnerability_ids = list(
47-
TriageRecord.objects.filter(
48-
ruleset=instance.ruleset,
49-
product=instance.product,
50-
).values_list("vulnerability_id", flat=True)
51-
)
52-
TriageRecord.objects.filter(
48+
matching_records = TriageRecord.objects.filter(
5349
ruleset=instance.ruleset,
5450
product=instance.product,
55-
).delete()
51+
)
52+
stale_vulnerability_ids = list(matching_records.values_list("vulnerability_id", flat=True))
53+
# Records with an open Request are kept so reassigning the ruleset reconnects to it
54+
# instead of opening a duplicate Request.
55+
matching_records.filter(request__isnull=True).delete()
5656
if instance.ruleset.analysis_preset_id and stale_vulnerability_ids:
5757
delete_preset_analyses_for_product(
5858
preset_id=instance.ruleset.analysis_preset_id,

vulnerabilities/triage/tests/test_engine.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,37 @@ def test_deletes_records_for_vulnerabilities_that_no_longer_match(self):
225225
sync_triage_records(self.ruleset, self.product, {})
226226
self.assertFalse(TriageRecord.objects.exists())
227227

228+
def test_keeps_a_stale_record_that_has_an_open_request(self):
229+
# A record with a Request already attached must survive going stale, so a later
230+
# rematch reconnects to the same Request instead of opening a duplicate.
231+
sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
232+
record = TriageRecord.objects.get()
233+
request = Request.objects.create(
234+
request_template=RequestTemplate.objects.create(
235+
name="Template",
236+
description="Header",
237+
dataspace=self.dataspace,
238+
content_type=ContentType.objects.get_for_model(Product),
239+
created_by=create_user("requester", self.dataspace),
240+
),
241+
dataspace=self.dataspace,
242+
requester=create_user("requester2", self.dataspace),
243+
title="Vulnerability request",
244+
product_context=self.product,
245+
)
246+
record.request = request
247+
record.save()
248+
249+
sync_triage_records(self.ruleset, self.product, {})
250+
self.assertEqual(1, TriageRecord.objects.count())
251+
record.refresh_from_db()
252+
self.assertEqual(request, record.request)
253+
254+
sync_triage_records(self.ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})
255+
self.assertEqual(1, TriageRecord.objects.count())
256+
record.refresh_from_db()
257+
self.assertEqual(request, record.request)
258+
228259
def test_does_not_touch_records_from_another_ruleset(self):
229260
other_ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.NOTIFY)
230261
sync_triage_records(other_ruleset, self.product, {self.vulnerability.pk: ["risk_score"]})

vulnerabilities/triage/tests/test_signals.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88

99
from unittest.mock import patch
1010

11+
from django.contrib.contenttypes.models import ContentType
1112
from django.test import TestCase
1213

1314
from component_catalog.tests import make_package
1415
from dje.models import Dataspace
16+
from dje.tests import create_user
17+
from product_portfolio.models import Product
1518
from product_portfolio.tests import make_product
1619
from product_portfolio.tests import make_product_package
1720
from vulnerabilities.models import VulnerabilityAnalysis
@@ -26,6 +29,8 @@
2629
from vulnerabilities.triage.tests import make_analysis_preset
2730
from vulnerabilities.triage.tests import make_product_triage_ruleset
2831
from vulnerabilities.triage.tests import make_triage_ruleset
32+
from workflow.models import Request
33+
from workflow.models import RequestTemplate
2934

3035

3136
class ReevaluateProductRulesetsTestCase(TestCase):
@@ -120,6 +125,76 @@ def test_disabling_a_ruleset_deletes_its_triage_records_instead_of_evaluating(
120125
self.assertFalse(TriageRecord.objects.exists())
121126
mock_evaluate.assert_not_called()
122127

128+
@patch("vulnerabilities.triage.signals.evaluate_ruleset")
129+
def test_disabling_a_ruleset_keeps_records_that_have_an_open_request(self, mock_evaluate):
130+
package = make_package(self.dataspace)
131+
make_product_package(self.product, package=package)
132+
vulnerability = make_vulnerability(self.dataspace, affecting=package)
133+
ruleset = make_triage_ruleset(self.dataspace, action=TriageAction.NOTIFY)
134+
make_product_triage_ruleset(self.product, ruleset=ruleset)
135+
requester = create_user("requester", self.dataspace)
136+
request_template = RequestTemplate.objects.create(
137+
name="Template",
138+
description="Header",
139+
dataspace=self.dataspace,
140+
content_type=ContentType.objects.get_for_model(Product),
141+
created_by=requester,
142+
)
143+
request = request_template.create_request(
144+
requester=requester,
145+
title="Vulnerability request",
146+
product_context=self.product,
147+
object_id=self.product.pk,
148+
)
149+
record = TriageRecord.objects.create(
150+
vulnerability=vulnerability,
151+
product=self.product,
152+
ruleset=ruleset,
153+
action=ruleset.action,
154+
request=request,
155+
dataspace=self.dataspace,
156+
)
157+
mock_evaluate.reset_mock()
158+
159+
ruleset.enabled = False
160+
ruleset.save()
161+
162+
record.refresh_from_db()
163+
self.assertEqual(request, record.request)
164+
165+
def test_disabling_then_reenabling_a_ruleset_reuses_the_existing_request(self):
166+
# Regression: disabling then re-enabling a ruleset used to reopen a new Request
167+
# instead of reconnecting to the one already tracking this vulnerability.
168+
package = make_package(self.dataspace)
169+
make_product_package(self.product, package=package)
170+
make_vulnerability(self.dataspace, affecting=package, risk_score=9.0)
171+
requester = create_user("requester", self.dataspace)
172+
request_template = RequestTemplate.objects.create(
173+
name="Template",
174+
description="Header",
175+
dataspace=self.dataspace,
176+
content_type=ContentType.objects.get_for_model(Product),
177+
created_by=requester,
178+
)
179+
ruleset = make_triage_ruleset(
180+
self.dataspace,
181+
action=TriageAction.NOTIFY,
182+
request_template=request_template,
183+
rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}},
184+
)
185+
make_product_triage_ruleset(self.product, ruleset=ruleset)
186+
evaluate_ruleset(ruleset, self.product)
187+
original_request = TriageRecord.objects.get().request
188+
self.assertIsNotNone(original_request)
189+
190+
ruleset.enabled = False
191+
ruleset.save()
192+
ruleset.enabled = True
193+
ruleset.save()
194+
195+
self.assertEqual(1, Request.objects.count())
196+
self.assertEqual(original_request, TriageRecord.objects.get().request)
197+
123198

124199
class DeleteTriageRecordsOnUnassignSignalTestCase(TestCase):
125200
def setUp(self):
@@ -143,6 +218,53 @@ def test_unassigning_the_ruleset_deletes_its_triage_records_for_the_product(self
143218
self.assignment.delete()
144219
self.assertFalse(TriageRecord.objects.exists())
145220

221+
def test_unassigning_keeps_a_record_that_has_an_open_request(self):
222+
requester = create_user("requester", self.dataspace)
223+
request_template = RequestTemplate.objects.create(
224+
name="Template",
225+
description="Header",
226+
dataspace=self.dataspace,
227+
content_type=ContentType.objects.get_for_model(Product),
228+
created_by=requester,
229+
)
230+
request = request_template.create_request(
231+
requester=requester,
232+
title="Vulnerability request",
233+
product_context=self.product,
234+
object_id=self.product.pk,
235+
)
236+
record = TriageRecord.objects.get()
237+
record.request = request
238+
record.save()
239+
240+
self.assignment.delete()
241+
242+
record.refresh_from_db()
243+
self.assertEqual(request, record.request)
244+
245+
def test_reassigning_the_ruleset_reuses_the_existing_request(self):
246+
# Regression: unassigning then reassigning a ruleset used to reopen a new Request
247+
# instead of reconnecting to the one already tracking this vulnerability.
248+
requester = create_user("requester", self.dataspace)
249+
request_template = RequestTemplate.objects.create(
250+
name="Template",
251+
description="Header",
252+
dataspace=self.dataspace,
253+
content_type=ContentType.objects.get_for_model(Product),
254+
created_by=requester,
255+
)
256+
self.ruleset.request_template = request_template
257+
self.ruleset.save()
258+
original_request = TriageRecord.objects.get().request
259+
self.assertIsNotNone(original_request)
260+
261+
self.assignment.delete()
262+
make_product_triage_ruleset(self.product, ruleset=self.ruleset)
263+
evaluate_ruleset(self.ruleset, self.product)
264+
265+
self.assertEqual(1, Request.objects.count())
266+
self.assertEqual(original_request, TriageRecord.objects.get().request)
267+
146268
def test_does_not_delete_records_belonging_to_another_product(self):
147269
other_product = make_product(self.dataspace)
148270
make_product_package(other_product, package=self.package)

0 commit comments

Comments
 (0)