Skip to content

Commit 89ac05a

Browse files
enhance/normalize EDITABLE_MITIGATED_DATA handling (#13303)
* enhance/normalize EDITABLE_MITIGATED_DATA handling * add tests, fix bugs * fix validation * fixes * fix close finding form
1 parent 77d6bdd commit 89ac05a

File tree

6 files changed

+152
-10
lines changed

6 files changed

+152
-10
lines changed

dojo/api_v2/serializers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1676,6 +1676,8 @@ class Meta:
16761676

16771677

16781678
class FindingSerializer(serializers.ModelSerializer):
1679+
mitigated = serializers.DateTimeField(required=False, allow_null=True)
1680+
mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all())
16791681
tags = TagListSerializerField(required=False)
16801682
request_response = serializers.SerializerMethodField()
16811683
accepted_risks = RiskAcceptanceSerializer(
@@ -1772,6 +1774,21 @@ def update(self, instance, validated_data):
17721774
return instance
17731775

17741776
def validate(self, data):
1777+
# Enforce mitigated metadata editability (only when non-null values are provided)
1778+
attempting_to_set_mitigated = any(
1779+
(field in data) and (data.get(field) is not None)
1780+
for field in ["mitigated", "mitigated_by"]
1781+
)
1782+
user = getattr(self.context.get("request", None), "user", None)
1783+
if attempting_to_set_mitigated and not finding_helper.can_edit_mitigated_data(user):
1784+
errors = {}
1785+
if ("mitigated" in data) and (data.get("mitigated") is not None):
1786+
errors["mitigated"] = ["Editing mitigated timestamp is disabled (EDITABLE_MITIGATED_DATA=false)"]
1787+
if ("mitigated_by" in data) and (data.get("mitigated_by") is not None):
1788+
errors["mitigated_by"] = ["Editing mitigated_by is disabled (EDITABLE_MITIGATED_DATA=false)"]
1789+
if errors:
1790+
raise serializers.ValidationError(errors)
1791+
17751792
if self.context["request"].method == "PATCH":
17761793
is_active = data.get("active", self.instance.active)
17771794
is_verified = data.get("verified", self.instance.verified)
@@ -1841,6 +1858,8 @@ def get_request_response(self, obj):
18411858

18421859

18431860
class FindingCreateSerializer(serializers.ModelSerializer):
1861+
mitigated = serializers.DateTimeField(required=False, allow_null=True)
1862+
mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all())
18441863
notes = serializers.PrimaryKeyRelatedField(
18451864
read_only=True, allow_null=True, required=False, many=True,
18461865
)
@@ -1909,6 +1928,21 @@ def create(self, validated_data):
19091928
return new_finding
19101929

19111930
def validate(self, data):
1931+
# Ensure mitigated fields are only set when editable is enabled (ignore nulls)
1932+
attempting_to_set_mitigated = any(
1933+
(field in data) and (data.get(field) is not None)
1934+
for field in ["mitigated", "mitigated_by"]
1935+
)
1936+
user = getattr(getattr(self.context, "request", None), "user", None)
1937+
if attempting_to_set_mitigated and not finding_helper.can_edit_mitigated_data(user):
1938+
errors = {}
1939+
if ("mitigated" in data) and (data.get("mitigated") is not None):
1940+
errors["mitigated"] = ["Editing mitigated timestamp is disabled (EDITABLE_MITIGATED_DATA=false)"]
1941+
if ("mitigated_by" in data) and (data.get("mitigated_by") is not None):
1942+
errors["mitigated_by"] = ["Editing mitigated_by is disabled (EDITABLE_MITIGATED_DATA=false)"]
1943+
if errors:
1944+
raise serializers.ValidationError(errors)
1945+
19121946
if "reporter" not in data:
19131947
request = self.context["request"]
19141948
data["reporter"] = request.user

dojo/api_v2/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,7 @@ def close(self, request, pk=None):
937937
finding=finding,
938938
user=request.user,
939939
is_mitigated=finding_close.validated_data["is_mitigated"],
940-
mitigated=(finding_close.validated_data.get("mitigated") if settings.EDITABLE_MITIGATED_DATA else timezone.now()),
940+
mitigated=(finding_close.validated_data.get("mitigated") if finding_helper.can_edit_mitigated_data(request.user) else timezone.now()),
941941
mitigated_by=finding_close.validated_data.get("mitigated_by") or (request.user if not finding_helper.can_edit_mitigated_data(request.user) else None),
942942
false_p=finding_close.validated_data.get("false_p", False),
943943
out_of_scope=finding_close.validated_data.get("out_of_scope", False),

dojo/finding/helper.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,13 @@ def update_finding_status(new_state_finding, user, changed_fields=None):
129129
new_state_finding.mitigated = None
130130
new_state_finding.mitigated_by = None
131131

132-
# people may try to remove mitigated/mitigated_by by accident
132+
# Ensure mitigated metadata is present for mitigated findings
133+
# If values are provided (including custom ones), keep them; if missing, set defaults
133134
if new_state_finding.is_mitigated:
134-
new_state_finding.mitigated = new_state_finding.mitigated or now
135-
new_state_finding.mitigated_by = new_state_finding.mitigated_by or user
135+
if not new_state_finding.mitigated:
136+
new_state_finding.mitigated = now
137+
if not new_state_finding.mitigated_by:
138+
new_state_finding.mitigated_by = user
136139

137140
if is_new_finding or "active" in changed_fields:
138141
# finding is being (re)activated
@@ -185,7 +188,7 @@ def filter_findings_by_existence(findings):
185188

186189

187190
def can_edit_mitigated_data(user):
188-
return settings.EDITABLE_MITIGATED_DATA and user.is_superuser
191+
return settings.EDITABLE_MITIGATED_DATA and user and getattr(user, "is_superuser", False)
189192

190193

191194
def create_finding_group(finds, finding_group_name):

dojo/finding/views.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,9 +1144,16 @@ def close_finding(request, fid):
11441144
# we can do this with a Note
11451145
note_type_activation = Note_Type.objects.filter(is_active=True)
11461146
missing_note_types = get_missing_mandatory_notetypes(finding) if len(note_type_activation) else note_type_activation
1147-
form = CloseFindingForm(missing_note_types=missing_note_types)
1147+
form = CloseFindingForm(
1148+
missing_note_types=missing_note_types,
1149+
can_edit_mitigated_data=finding_helper.can_edit_mitigated_data(request.user),
1150+
)
11481151
if request.method == "POST":
1149-
form = CloseFindingForm(request.POST, missing_note_types=missing_note_types)
1152+
form = CloseFindingForm(
1153+
request.POST,
1154+
missing_note_types=missing_note_types,
1155+
can_edit_mitigated_data=finding_helper.can_edit_mitigated_data(request.user),
1156+
)
11501157

11511158
if form.is_valid():
11521159
messages.add_message(request, messages.SUCCESS, "Note Saved.", extra_tags="alert-success")

dojo/forms.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,15 +1917,15 @@ class CloseFindingForm(forms.ModelForm):
19171917

19181918
def __init__(self, *args, **kwargs):
19191919
queryset = kwargs.pop("missing_note_types")
1920+
# must pop custom kwargs before calling parent __init__ to avoid unexpected kwarg errors
1921+
self.can_edit_mitigated_data = kwargs.pop("can_edit_mitigated_data") if "can_edit_mitigated_data" in kwargs \
1922+
else False
19201923
super().__init__(*args, **kwargs)
19211924
if len(queryset) == 0:
19221925
self.fields["note_type"].widget = forms.HiddenInput()
19231926
else:
19241927
self.fields["note_type"] = forms.ModelChoiceField(queryset=queryset, label="Note Type", required=True)
19251928

1926-
self.can_edit_mitigated_data = kwargs.pop("can_edit_mitigated_data") if "can_edit_mitigated_data" in kwargs \
1927-
else False
1928-
19291929
if self.can_edit_mitigated_data:
19301930
self.fields["mitigated_by"].queryset = get_authorized_users(Permissions.Finding_Edit)
19311931
self.fields["mitigated"].initial = self.instance.mitigated

unittests/test_rest_framework.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pathlib
55
import re
66
from collections import OrderedDict
7+
from datetime import timedelta
78
from enum import Enum
89
from json import dumps
910
from pathlib import Path
@@ -14,6 +15,7 @@
1415
from django.contrib.auth.models import Permission
1516
from django.test import tag as test_tag
1617
from django.urls import reverse
18+
from django.utils import timezone
1719
from drf_spectacular.drainage import GENERATOR_STATS
1820
from drf_spectacular.settings import spectacular_settings
1921
from drf_spectacular.validation import validate_schema
@@ -919,6 +921,102 @@ def test_close_finding_pushes_note_to_jira_when_configured(self):
919921
self.assertTrue(add_comment_mock.called)
920922

921923

924+
class FindingCreateUpdateMitigatedFieldsAPITest(DojoAPITestCase):
925+
fixtures = ["dojo_testdata.json"]
926+
927+
def setUp(self):
928+
testuser = User.objects.get(username="admin")
929+
token = Token.objects.get(user=testuser)
930+
self.client = APIClient()
931+
self.client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
932+
self.admin = testuser
933+
self.base_url = "/api/v2/findings/"
934+
935+
def _minimal_create_payload(self, title: str):
936+
return {
937+
"test": 3,
938+
"found_by": [],
939+
"title": title,
940+
"date": "2020-05-20",
941+
"cwe": 1,
942+
"severity": "High",
943+
"description": "TEST finding",
944+
"mitigation": "MITIGATION",
945+
"impact": "HIGH",
946+
"references": "",
947+
"active": False,
948+
"verified": False,
949+
"false_p": False,
950+
"duplicate": False,
951+
"out_of_scope": False,
952+
"under_review": False,
953+
"under_defect_review": False,
954+
"numerical_severity": "S0",
955+
}
956+
957+
def test_create_rejects_mitigated_fields_by_default(self):
958+
payload = self._minimal_create_payload("API create with mitigated disallowed")
959+
past = (timezone.now() - timedelta(days=7)).replace(microsecond=0)
960+
payload.update({
961+
"is_mitigated": True,
962+
"mitigated": past.isoformat(),
963+
"mitigated_by": self.admin.id,
964+
})
965+
966+
response = self.client.post(self.base_url, payload, format="json")
967+
self.assertEqual(400, response.status_code, response.content[:1000])
968+
self.assertIn("mitigated", response.data)
969+
self.assertIn("mitigated_by", response.data)
970+
971+
def test_update_rejects_mitigated_fields_by_default(self):
972+
finding_id = 7
973+
past = (timezone.now() - timedelta(days=7)).replace(microsecond=0)
974+
payload = {
975+
"is_mitigated": True,
976+
"mitigated": past.isoformat(),
977+
"mitigated_by": self.admin.id,
978+
}
979+
response = self.client.patch(f"{self.base_url}{finding_id}/", payload, format="json")
980+
self.assertEqual(400, response.status_code, response.content[:1000])
981+
self.assertIn("mitigated", response.data)
982+
self.assertIn("mitigated_by", response.data)
983+
984+
def test_create_sets_mitigated_fields_when_permitted(self):
985+
with patch("dojo.finding.helper.can_edit_mitigated_data", return_value=True):
986+
payload = self._minimal_create_payload("API create with mitigated allowed")
987+
past = (timezone.now() - timedelta(days=7)).replace(microsecond=0)
988+
payload.update({
989+
"is_mitigated": True,
990+
"mitigated": past.isoformat(),
991+
"mitigated_by": self.admin.id,
992+
})
993+
994+
response = self.client.post(self.base_url, payload, format="json")
995+
self.assertEqual(201, response.status_code, response.content[:1000])
996+
997+
created_id = response.data.get("id")
998+
self.assertIsNotNone(created_id)
999+
created = Finding.objects.get(id=created_id)
1000+
self.assertEqual(created.mitigated.replace(microsecond=0, tzinfo=created.mitigated.tzinfo), past)
1001+
self.assertEqual(created.mitigated_by, self.admin)
1002+
1003+
def test_update_sets_mitigated_fields_when_permitted(self):
1004+
finding_id = 7
1005+
with patch("dojo.finding.helper.can_edit_mitigated_data", return_value=True):
1006+
past = (timezone.now() - timedelta(days=7)).replace(microsecond=0)
1007+
payload = {
1008+
"is_mitigated": True,
1009+
"mitigated": past.isoformat(),
1010+
"mitigated_by": self.admin.id,
1011+
}
1012+
response = self.client.patch(f"{self.base_url}{finding_id}/", payload, format="json")
1013+
self.assertEqual(200, response.status_code, response.content[:1000])
1014+
1015+
updated = Finding.objects.get(id=finding_id)
1016+
self.assertEqual(updated.mitigated.replace(microsecond=0, tzinfo=updated.mitigated.tzinfo), past)
1017+
self.assertEqual(updated.mitigated_by, self.admin)
1018+
1019+
9221020
class EndpointStatusTest(BaseClass.BaseClassTest):
9231021
fixtures = ["dojo_testdata.json"]
9241022

0 commit comments

Comments
 (0)