Skip to content

Commit 2e841bf

Browse files
MaffoochDefectDojo Bot
andauthored
fix(risk_acceptance): stop the expiration job aborting on an unattached risk acceptance (#15376)
* fix(risk_acceptance): stop the expiration job aborting on an unattached risk acceptance `expiration_handler` raised `AttributeError: 'NoneType' object has no attribute 'product'` and aborted the whole run whenever it reached a risk acceptance that no engagement points at. `Risk_Acceptance.engagement` is a property over a many-to-many used as a one-to-many, so it returns None in that state, and both halves of the job dereferenced it unconditionally while building the notification. Impact was not limited to the one bad row: every risk acceptance later in the batch was left unhandled, and because `expiration_date_warned` is only set after the notification, the same risk acceptance was re-selected and failed the job again on every subsequent run. Resolve the engagement once through `engagement_to_notify_about()`, which logs and returns None when there is none, and skip only the notification in that case. The expiry itself, the finding reactivation and the JIRA comments still happen, and the heads-up path now marks the risk acceptance as warned either way so it stops being re-selected. Adds two regression tests covering the warning path and the expiry path; both reproduce the reported traceback before the change. * fix(risk_acceptance): derive the notification engagement from the accepted findings The previous commit stopped the crash but skipped the expiration notification whenever `Risk_Acceptance.engagement` was empty. That is too blunt: an empty engagement relation is a normal state for a populated risk acceptance, not a sign there is nobody to notify. `RiskAcceptanceSerializer.create` only attaches an engagement when the risk acceptance already has findings; `_accept_risks` and the SonarQube status sync never attach one; and the Pro plugin tracks the association on its own model rather than on `Engagement.risk_acceptance`. Skipping on an empty relation would therefore have dropped expiration notifications for whole classes of risk acceptance that have findings, a product, and users expecting to hear about them. Fall back to the engagement of an accepted finding instead - the same derivation `RiskAcceptanceSerializer` already uses to attach an engagement on create and to repair an orphan on update. Only a risk acceptance with neither an engagement nor findings is skipped, because the notification names a product and links to a per-engagement URL and neither can be produced from an empty one. Tests now cover the three cases separately: engagement link missing but findings present (notification still sent, addressed via the derived engagement, asserted on both the engagement and the product), no engagement and no findings (skipped, still marked warned so it is not re-selected), and the same on the expiry half. All three fail against unmodified `bugfix` at helper.py:54 and helper.py:203. --------- Co-authored-by: DefectDojo Bot <bot@defectdojo.com>
1 parent c603cd9 commit 2e841bf

2 files changed

Lines changed: 202 additions & 12 deletions

File tree

dojo/risk_acceptance/helper.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,46 @@
99

1010
from dojo.celery import app
1111
from dojo.jira import services as jira_services
12-
from dojo.models import Dojo_User, Finding, Notes, Risk_Acceptance, System_Settings
12+
from dojo.models import Dojo_User, Engagement, Finding, Notes, Risk_Acceptance, System_Settings
1313
from dojo.notifications.helper import create_notification
1414
from dojo.utils import get_full_url, get_system_setting
1515

1616
logger = logging.getLogger(__name__)
1717

1818

19+
def engagement_to_notify_about(risk_acceptance: Risk_Acceptance) -> Engagement | None:
20+
"""
21+
Return an engagement to address a risk acceptance's expiration notification to.
22+
23+
`Risk_Acceptance.engagement` is a property over `Engagement.risk_acceptance`, a
24+
many-to-many used as a one-to-many. It is empty for a risk acceptance that was not
25+
created from an engagement page, which is a supported state rather than corrupt data:
26+
`RiskAcceptanceSerializer.create` only attaches an engagement when the risk acceptance
27+
already has findings, `_accept_risks` and the SonarQube status sync never attach one at
28+
all, and the Pro plugin tracks the association on its own model instead of this relation.
29+
30+
So fall back to the engagement of an accepted finding - the same derivation
31+
`RiskAcceptanceSerializer` uses both to attach an engagement on create and to repair an
32+
orphan on update. Only a risk acceptance with neither an engagement nor any findings has
33+
nothing to notify about; the notification names a product and links to a per-engagement
34+
URL, and neither can be produced from an empty risk acceptance.
35+
"""
36+
engagement = risk_acceptance.engagement
37+
if engagement is not None:
38+
return engagement
39+
40+
# iterate rather than .first(), to reuse the accepted_findings prefetch
41+
accepted_finding = next(iter(risk_acceptance.accepted_findings.all()), None)
42+
if accepted_finding is not None:
43+
return accepted_finding.test.engagement
44+
45+
logger.warning(
46+
"risk acceptance %i:%s has no engagement and no findings, skipping its expiration notification",
47+
risk_acceptance.id, risk_acceptance,
48+
)
49+
return None
50+
51+
1952
def expire_now(risk_acceptance):
2053
logger.info("Expiring risk acceptance %i:%s with %i findings", risk_acceptance.id, risk_acceptance, len(risk_acceptance.accepted_findings.all()))
2154

@@ -49,14 +82,19 @@ def expire_now(risk_acceptance):
4982
risk_acceptance.expiration_date_handled = timezone.now()
5083
risk_acceptance.save()
5184

85+
# the expiry above is the job here, the notification below is best effort
86+
engagement = engagement_to_notify_about(risk_acceptance)
87+
if engagement is None:
88+
return
89+
5290
accepted_findings = risk_acceptance.accepted_findings.all()
5391
title = "Risk acceptance with " + str(len(accepted_findings)) + " accepted findings has expired for " + \
54-
str(risk_acceptance.engagement.product) + ": " + str(risk_acceptance.engagement.name)
92+
str(engagement.product) + ": " + str(engagement.name)
5593

5694
create_notification(event="risk_acceptance_expiration", title=title, risk_acceptance=risk_acceptance, accepted_findings=accepted_findings,
57-
reactivated_findings=reactivated_findings, engagement=risk_acceptance.engagement,
58-
product=risk_acceptance.engagement.product,
59-
url=reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id)))
95+
reactivated_findings=reactivated_findings, engagement=engagement,
96+
product=engagement.product,
97+
url=reverse("view_risk_acceptance", args=(engagement.id, risk_acceptance.id)))
6098

6199

62100
def reinstate(risk_acceptance, old_expiration_date):
@@ -198,17 +236,21 @@ def expiration_handler(*args, **kwargs):
198236
for risk_acceptance in risk_acceptances:
199237
logger.debug("notifying for risk acceptance %i:%s with %i findings", risk_acceptance.id, risk_acceptance, len(risk_acceptance.accepted_findings.all()))
200238

201-
notification_title = "Risk acceptance with " + str(len(risk_acceptance.accepted_findings.all())) + " accepted findings will expire on " + \
202-
timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y") + " for " + \
203-
str(risk_acceptance.engagement.product) + ": " + str(risk_acceptance.engagement.name)
239+
engagement = engagement_to_notify_about(risk_acceptance)
240+
if engagement is not None:
241+
notification_title = "Risk acceptance with " + str(len(risk_acceptance.accepted_findings.all())) + " accepted findings will expire on " + \
242+
timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y") + " for " + \
243+
str(engagement.product) + ": " + str(engagement.name)
204244

205-
create_notification(event="risk_acceptance_expiration", title=notification_title, risk_acceptance=risk_acceptance,
206-
accepted_findings=risk_acceptance.accepted_findings.all(), engagement=risk_acceptance.engagement,
207-
product=risk_acceptance.engagement.product,
208-
url=reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id)))
245+
create_notification(event="risk_acceptance_expiration", title=notification_title, risk_acceptance=risk_acceptance,
246+
accepted_findings=risk_acceptance.accepted_findings.all(), engagement=engagement,
247+
product=engagement.product,
248+
url=reverse("view_risk_acceptance", args=(engagement.id, risk_acceptance.id)))
209249

210250
post_jira_comments(risk_acceptance, risk_acceptance.accepted_findings.all(), expiration_warning_message_creator, heads_up_days)
211251

252+
# marked as warned either way, so a risk acceptance nobody can be notified about
253+
# is not re-selected on every subsequent run
212254
risk_acceptance.expiration_date_warned = timezone.now()
213255
risk_acceptance.save()
214256

unittests/test_risk_acceptance.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import copy
22
import datetime
33
import logging
4+
from unittest.mock import patch
45

56
from dateutil.relativedelta import relativedelta
67
from django.db.models import Q
@@ -299,3 +300,150 @@ def test_expiration_handler(self):
299300
# after handling no ra should be select for anything
300301
self.assertFalse(any(ra in to_warn for ra in [ra1, ra2, ra3]))
301302
self.assertFalse(any(ra in to_expire for ra in [ra1, ra2, ra3]))
303+
304+
def detach_from_engagements(self, ra):
305+
"""
306+
Leave a risk acceptance without an engagement.
307+
308+
Not corrupt data: `RiskAcceptanceSerializer.create` only attaches an engagement when
309+
the risk acceptance already has findings, and the Pro plugin tracks the association on
310+
its own model rather than on `Engagement.risk_acceptance`.
311+
"""
312+
for engagement in ra.engagement_set.all():
313+
engagement.risk_acceptance.remove(ra)
314+
ra.refresh_from_db()
315+
self.assertIsNone(ra.engagement, msg="setup failed: risk acceptance is still attached to an engagement")
316+
317+
def notified_risk_acceptances(self, mock_create_notification):
318+
return [call.kwargs.get("risk_acceptance") for call in mock_create_notification.call_args_list]
319+
320+
# Regression: expiration_handler aborted the entire run with
321+
# "AttributeError: 'NoneType' object has no attribute 'product'" as soon as it reached a
322+
# risk acceptance not attached to any engagement, so every remaining risk acceptance in the
323+
# batch was left unhandled and the job failed again on every subsequent run.
324+
def test_expiration_handler_warns_via_findings_when_engagement_link_is_missing(self):
325+
ra1, ra2, ra3 = self.create_multiple_ras()
326+
system_settings = System_Settings.objects.get()
327+
system_settings.risk_acceptance_notify_before_expiration = 10
328+
system_settings.save()
329+
heads_up_days = system_settings.risk_acceptance_notify_before_expiration
330+
331+
# both are inside the heads-up window, ra1 (created first, so handled first) has no engagement
332+
ra1.expiration_date = datetime.datetime.now(datetime.UTC) + relativedelta(days=heads_up_days - 1)
333+
ra2.expiration_date = datetime.datetime.now(datetime.UTC) + relativedelta(days=heads_up_days - 1)
334+
ra3.expiration_date = datetime.datetime.now(datetime.UTC) + relativedelta(days=heads_up_days + 1)
335+
ra1.save()
336+
ra2.save()
337+
ra3.save()
338+
expected_engagement = ra1.accepted_findings.first().test.engagement
339+
self.detach_from_engagements(ra1)
340+
341+
with patch("dojo.risk_acceptance.helper.create_notification") as mock_create_notification:
342+
ra_helper.expiration_handler()
343+
344+
ra1.refresh_from_db()
345+
ra2.refresh_from_db()
346+
347+
# the missing link is recovered from the accepted findings rather than dropping the notification
348+
notified = self.notified_risk_acceptances(mock_create_notification)
349+
self.assertIn(ra1, notified, msg=f"risk acceptance with findings but no engagement was not notified about, got {notified}")
350+
self.assertIn(ra2, notified, msg=f"expected a notification for the attached risk acceptance, got {notified}")
351+
352+
ra1_call = next(c for c in mock_create_notification.call_args_list if c.kwargs.get("risk_acceptance") == ra1)
353+
self.assertEqual(
354+
ra1_call.kwargs.get("engagement"), expected_engagement,
355+
msg=f"expected engagement={expected_engagement}, notified with {ra1_call.kwargs.get('engagement')}",
356+
)
357+
self.assertEqual(
358+
ra1_call.kwargs.get("product"), expected_engagement.product,
359+
msg=f"expected product={expected_engagement.product}, notified with {ra1_call.kwargs.get('product')}",
360+
)
361+
362+
self.assertIsNotNone(ra1.expiration_date_warned, msg="risk acceptance with no engagement link was never warned")
363+
self.assertIsNotNone(ra2.expiration_date_warned, msg="attached risk acceptance was never warned")
364+
365+
# Regression: the shape produced by the Pro standalone "New Risk Acceptance" page, which
366+
# pre-fills an expiration date and posts no findings, so the risk acceptance is selected by
367+
# the heads-up query while having neither an engagement nor anything to derive one from.
368+
def test_expiration_handler_skips_risk_acceptance_with_no_engagement_and_no_findings(self):
369+
ra1, ra2, ra3 = self.create_multiple_ras()
370+
system_settings = System_Settings.objects.get()
371+
system_settings.risk_acceptance_notify_before_expiration = 10
372+
system_settings.save()
373+
heads_up_days = system_settings.risk_acceptance_notify_before_expiration
374+
375+
for ra in (ra1, ra2, ra3):
376+
ra.expiration_date = datetime.datetime.now(datetime.UTC) + relativedelta(days=heads_up_days + 1)
377+
ra.save()
378+
379+
empty_ra = Risk_Acceptance.objects.create(
380+
name="Accept: no findings, no engagement",
381+
owner=self.get_test_admin(),
382+
expiration_date=datetime.datetime.now(datetime.UTC) + relativedelta(days=heads_up_days - 1),
383+
)
384+
attached_ra = ra1
385+
attached_ra.expiration_date = datetime.datetime.now(datetime.UTC) + relativedelta(days=heads_up_days - 1)
386+
attached_ra.save()
387+
self.assertIsNone(empty_ra.engagement, msg="setup failed: expected no engagement")
388+
self.assertFalse(empty_ra.accepted_findings.exists(), msg="setup failed: expected no accepted findings")
389+
self.assertIn(
390+
empty_ra, ra_helper.get_almost_expired_risk_acceptances_to_handle(heads_up_days=heads_up_days),
391+
msg="setup failed: the empty risk acceptance is not selected by the heads-up query",
392+
)
393+
394+
with patch("dojo.risk_acceptance.helper.create_notification") as mock_create_notification:
395+
ra_helper.expiration_handler()
396+
397+
empty_ra.refresh_from_db()
398+
attached_ra.refresh_from_db()
399+
400+
# nothing to name a product with, so no notification - but the job must not die over it
401+
notified = self.notified_risk_acceptances(mock_create_notification)
402+
self.assertNotIn(empty_ra, notified, msg="a risk acceptance with no findings has no product to notify about")
403+
self.assertIn(attached_ra, notified, msg=f"the rest of the batch was abandoned, notified: {notified}")
404+
405+
self.assertIsNotNone(
406+
empty_ra.expiration_date_warned,
407+
msg="empty risk acceptance stays unwarned and is re-selected, failing the job again on every run",
408+
)
409+
self.assertIsNotNone(attached_ra.expiration_date_warned, msg="attached risk acceptance was never warned")
410+
411+
# Regression: same root cause, on the expiry half of the job.
412+
def test_expiration_handler_expires_risk_acceptance_without_engagement_link(self):
413+
ra1, ra2, ra3 = self.create_multiple_ras()
414+
system_settings = System_Settings.objects.get()
415+
system_settings.risk_acceptance_notify_before_expiration = 10
416+
system_settings.save()
417+
heads_up_days = system_settings.risk_acceptance_notify_before_expiration
418+
419+
# ra3 is past its expiration date and has no engagement, ra1 is only due a heads-up
420+
ra1.expiration_date = datetime.datetime.now(datetime.UTC) + relativedelta(days=heads_up_days - 1)
421+
ra2.expiration_date = datetime.datetime.now(datetime.UTC) + relativedelta(days=heads_up_days + 1)
422+
ra3.expiration_date = datetime.datetime.now(datetime.UTC) - relativedelta(days=5)
423+
ra1.save()
424+
ra2.save()
425+
ra3.save()
426+
findings = list(ra3.accepted_findings.all())
427+
expected_engagement = ra3.accepted_findings.first().test.engagement
428+
self.detach_from_engagements(ra3)
429+
430+
with patch("dojo.risk_acceptance.helper.create_notification") as mock_create_notification:
431+
ra_helper.expiration_handler()
432+
433+
ra1.refresh_from_db()
434+
ra3.refresh_from_db()
435+
436+
# the expiry happens and the notification still goes out, addressed via the findings
437+
self.assertIsNotNone(ra3.expiration_date_handled, msg="risk acceptance with no engagement link was never expired")
438+
self.assert_all_active_not_risk_accepted(findings)
439+
440+
notified = self.notified_risk_acceptances(mock_create_notification)
441+
self.assertIn(ra3, notified, msg=f"expired risk acceptance with findings was not notified about, got {notified}")
442+
ra3_call = next(c for c in mock_create_notification.call_args_list if c.kwargs.get("risk_acceptance") == ra3)
443+
self.assertEqual(
444+
ra3_call.kwargs.get("engagement"), expected_engagement,
445+
msg=f"expected engagement={expected_engagement}, notified with {ra3_call.kwargs.get('engagement')}",
446+
)
447+
448+
# the rest of the batch is still processed instead of being abandoned mid-run
449+
self.assertIsNotNone(ra1.expiration_date_warned, msg="the run stopped at the risk acceptance with no engagement link")

0 commit comments

Comments
 (0)