Skip to content

Commit b41b23c

Browse files
authored
feat: edit VEX information in assessment approvals (SecObserve#4467)
* feat: edit VEX information in assessment approvals * chore: pylint
1 parent 7ba521b commit b41b23c

6 files changed

Lines changed: 86 additions & 19 deletions

File tree

backend/application/core/api/serializers_observation.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,12 @@ class ObservationLogApprovalSerializer(Serializer):
632632
assessment_status = ChoiceField(choices=Assessment_Status.ASSESSMENT_STATUS_CHOICES_APPROVAL, required=True)
633633
rejection_remark = CharField(max_length=255, required=False, allow_blank=True)
634634
observation_log_comment = CharField(max_length=4096, required=False, allow_blank=True)
635+
observation_log_vex_justification = ChoiceField(
636+
choices=VEX_Justification.VEX_JUSTIFICATION_CHOICES,
637+
required=False,
638+
allow_blank=True,
639+
)
640+
observation_log_vex_remediations = JSONField(required=False, allow_null=True)
635641

636642
def validate(self, attrs: dict) -> dict:
637643
if attrs.get("assessment_status") in [
@@ -648,8 +654,13 @@ def validate(self, attrs: dict) -> dict:
648654
if attrs.get("assessment_status") in [
649655
Assessment_Status.ASSESSMENT_STATUS_APPROVED,
650656
Assessment_Status.ASSESSMENT_STATUS_REJECTED,
651-
] and attrs.get("observation_log_comment"):
652-
raise ValidationError("Comment for observation Log cannot be set with approval or rejection")
657+
]:
658+
if attrs.get("observation_log_comment"):
659+
raise ValidationError("Comment for observation Log cannot be set with approval or rejection")
660+
if attrs.get("observation_log_vex_justification"):
661+
raise ValidationError("VEX justification for observation log cannot be set with approval or rejection")
662+
if attrs.get("observation_log_vex_remediations"):
663+
raise ValidationError("VEX remediation for observation log cannot be set with approval or rejection")
653664

654665
if attrs.get("assessment_status") == Assessment_Status.ASSESSMENT_STATUS_APPROVED_WITH_EDITS and not attrs.get(
655666
"observation_log_comment"

backend/application/core/api/views_observation.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ def approval(self, request: Request, pk: int) -> Response:
325325
request_serializer.validated_data.get("assessment_status"),
326326
request_serializer.validated_data.get("rejection_remark"),
327327
request_serializer.validated_data.get("observation_log_comment"),
328+
request_serializer.validated_data.get("observation_log_vex_justification"),
329+
request_serializer.validated_data.get("observation_log_vex_remediations"),
328330
)
329331

330332
set_potential_duplicate_both_ways(observation_log.observation)

backend/application/core/services/assessment.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -363,11 +363,13 @@ def remove_assessment(observation: Observation, comment: str) -> bool:
363363
return False
364364

365365

366-
def assessment_approval(
366+
def assessment_approval( # pylint: disable=too-many-positional-arguments
367367
observation_log: Observation_Log,
368368
assessment_status: str,
369369
rejection_remark: Optional[str],
370370
observation_log_comment: Optional[str],
371+
observation_log_vex_justification: Optional[str],
372+
observation_log_vex_remediations: Optional[str],
371373
) -> None:
372374
if observation_log.assessment_status != Assessment_Status.ASSESSMENT_STATUS_NEEDS_APPROVAL:
373375
raise ValidationError("Observation log does not need approval")
@@ -380,8 +382,13 @@ def assessment_approval(
380382
if not user_is_allowed_assessment_approver(product, approval_user):
381383
raise ValidationError("User is not an allowed approver for this product")
382384

383-
if assessment_status == Assessment_Status.ASSESSMENT_STATUS_APPROVED_WITH_EDITS and observation_log_comment:
384-
observation_log.comment = observation_log_comment
385+
if assessment_status == Assessment_Status.ASSESSMENT_STATUS_APPROVED_WITH_EDITS:
386+
if observation_log_comment:
387+
observation_log.comment = observation_log_comment
388+
if observation_log_vex_justification:
389+
observation_log.vex_justification = observation_log_vex_justification
390+
if observation_log_vex_remediations:
391+
observation_log.vex_remediations = observation_log_vex_remediations
385392
observation_log.save()
386393

387394
if assessment_status in (

backend/application/core/services/observations_bulk_actions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def observation_logs_bulk_approval(
139139
) -> None:
140140
observation_logs = _check_observation_logs(None, observation_log_ids)
141141
for observation_log in observation_logs:
142-
assessment_approval(observation_log, assessment_status, rejection_remark, None)
142+
assessment_approval(observation_log, assessment_status, rejection_remark, None, None, None)
143143
set_potential_duplicate_both_ways(observation_log.observation)
144144

145145

backend/unittests/core/services/test_assessment.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ def setUp(self) -> None:
204204
@patch("application.core.services.assessment.get_current_user")
205205
def test_empty_configuration_allows_any_non_author(self, mock_user) -> None:
206206
mock_user.return_value = self.outsider
207-
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None)
207+
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None, None, None)
208208
self.log.refresh_from_db()
209209
self.assertEqual(self.log.assessment_status, Assessment_Status.ASSESSMENT_STATUS_REJECTED)
210210
self.assertEqual(self.log.approval_user, self.outsider)
@@ -213,7 +213,7 @@ def test_empty_configuration_allows_any_non_author(self, mock_user) -> None:
213213
def test_designated_approver_can_approve(self, mock_user) -> None:
214214
self.product.assessment_approvers.add(self.approver)
215215
mock_user.return_value = self.approver
216-
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None)
216+
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None, None, None)
217217
self.log.refresh_from_db()
218218
self.assertEqual(self.log.approval_user, self.approver)
219219

@@ -227,7 +227,7 @@ def test_direct_writer_designated_user_with_reader_group_membership_can_approve(
227227
self.product.assessment_approvers.add(self.approver)
228228
mock_user.return_value = self.approver
229229

230-
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None)
230+
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None, None, None)
231231

232232
self.log.refresh_from_db()
233233
self.assertEqual(self.log.approval_user, self.approver)
@@ -243,7 +243,7 @@ def test_reader_group_designation_does_not_allow_direct_writer_member_to_approve
243243
mock_user.return_value = self.approver
244244

245245
with self.assertRaises(ValidationError):
246-
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None)
246+
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None, None, None)
247247

248248
self.log.refresh_from_db()
249249
self.assertEqual(self.log.assessment_status, Assessment_Status.ASSESSMENT_STATUS_NEEDS_APPROVAL)
@@ -263,7 +263,7 @@ def test_inherited_writer_group_member_with_direct_reader_role_can_approve(self,
263263
product_group.assessment_approver_authorization_groups.add(inherited_group)
264264
mock_user.return_value = inherited_group_user
265265

266-
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None)
266+
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None, None, None)
267267

268268
self.log.refresh_from_db()
269269
self.assertEqual(self.log.approval_user, inherited_group_user)
@@ -273,15 +273,15 @@ def test_non_approver_is_rejected_when_configured(self, mock_user) -> None:
273273
self.product.assessment_approvers.add(self.approver)
274274
mock_user.return_value = self.outsider
275275
with self.assertRaises(ValidationError):
276-
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None)
276+
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None, None, None)
277277
self.log.refresh_from_db()
278278
self.assertEqual(self.log.assessment_status, Assessment_Status.ASSESSMENT_STATUS_NEEDS_APPROVAL)
279279

280280
@patch("application.core.services.assessment.get_current_user")
281281
def test_owner_can_approve_other_users_assessments_when_not_designated(self, mock_user) -> None:
282282
self.product.assessment_approvers.add(self.approver)
283283
mock_user.return_value = self.owner
284-
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None)
284+
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None, None, None)
285285
self.log.refresh_from_db()
286286
self.assertEqual(self.log.approval_user, self.owner)
287287

@@ -292,7 +292,7 @@ def test_owner_self_approval_blocked_when_not_designated(self, mock_user) -> Non
292292
self.log.save()
293293
mock_user.return_value = self.owner
294294
with self.assertRaises(ValidationError):
295-
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None)
295+
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None, None, None)
296296

297297
@patch("application.core.services.assessment.get_current_user")
298298
def test_owner_self_approval_blocked_even_if_designated(self, mock_user) -> None:
@@ -301,7 +301,7 @@ def test_owner_self_approval_blocked_even_if_designated(self, mock_user) -> None
301301
self.log.save()
302302
mock_user.return_value = self.owner
303303
with self.assertRaises(ValidationError):
304-
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None)
304+
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None, None, None)
305305

306306
@patch("application.core.services.assessment.get_current_user")
307307
def test_non_owner_self_approval_blocked_even_if_designated_approver(self, mock_user) -> None:
@@ -310,7 +310,7 @@ def test_non_owner_self_approval_blocked_even_if_designated_approver(self, mock_
310310
self.log.save()
311311
mock_user.return_value = self.approver
312312
with self.assertRaises(ValidationError):
313-
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None)
313+
assessment_approval(self.log, Assessment_Status.ASSESSMENT_STATUS_REJECTED, "ok", None, None, None)
314314

315315
@patch("application.core.services.observations_bulk_actions.user_has_permission", return_value=True)
316316
@patch("application.core.services.observations_bulk_actions.get_current_user")

frontend/src/core/observation_logs/AssessmentApproval.tsx

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
11
import ApprovalIcon from "@mui/icons-material/Approval";
22
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
33
import { Fragment, useRef, useState } from "react";
4-
import { RaRecord, SimpleForm, useNotify, useRefresh } from "react-admin";
4+
import { ArrayInput, RaRecord, SimpleForm, SimpleFormIterator, useNotify, useRefresh } from "react-admin";
55

66
import MarkdownEdit from "../../commons/custom_fields/MarkdownEdit";
77
import SmallButton from "../../commons/custom_fields/SmallButton";
88
import { ToolbarCancelSave } from "../../commons/custom_fields/ToolbarCancelSave";
99
import { validate_required, validate_required_255 } from "../../commons/custom_validators";
10-
import { AutocompleteInputMedium, TextInputWide } from "../../commons/layout/themes";
10+
import {
11+
justificationIsEnabledForStatus,
12+
remediationsAreEnabledForStatus,
13+
settings_vex_justification_style,
14+
} from "../../commons/functions";
15+
import { AutocompleteInputMedium, AutocompleteInputWide, TextInputWide } from "../../commons/layout/themes";
1116
import { httpClient } from "../../commons/ra-data-django-rest-framework";
17+
import { VEX_JUSTIFICATION_TYPE_CSAF_OPENVEX, VEX_JUSTIFICATION_TYPE_CYCLONEDX } from "../../commons/types";
1218
import {
1319
ASSESSMENT_STATUS_APPROVED,
1420
ASSESSMENT_STATUS_APPROVED_WITH_EDITS,
1521
ASSESSMENT_STATUS_CHOICES,
1622
ASSESSMENT_STATUS_REJECTED,
23+
OBSERVATION_CYCLONEDX_VEX_JUSTIFICATION_CHOICES,
24+
OBSERVATION_VEX_JUSTIFICATION_CHOICES,
25+
OBSERVATION_VEX_REMEDIATION_CATEGORY_CHOICES,
1726
} from "../types";
1827

1928
type AssessmentApprovalProps = {
@@ -24,7 +33,9 @@ const AssessmentApproval = ({ observation_log }: AssessmentApprovalProps) => {
2433
const dialogRef = useRef<HTMLDivElement>(null);
2534
const [open, setOpen] = useState(false);
2635
const [decision, setDecision] = useState(ASSESSMENT_STATUS_APPROVED);
27-
const [comment, setComment] = useState("");
36+
const [comment, setComment] = useState(observation_log.comment);
37+
const justificationEnabled = justificationIsEnabledForStatus(observation_log.status);
38+
const remediationsEnabled = remediationsAreEnabledForStatus(observation_log.status);
2839
const refresh = useRefresh();
2940
const notify = useNotify();
3041

@@ -37,6 +48,12 @@ const AssessmentApproval = ({ observation_log }: AssessmentApprovalProps) => {
3748
}
3849
if (data.assessment_status === ASSESSMENT_STATUS_APPROVED_WITH_EDITS) {
3950
patch.observation_log_comment = comment;
51+
if (justificationEnabled) {
52+
patch.observation_log_vex_justification = data.vex_justification;
53+
}
54+
if (remediationsEnabled) {
55+
patch.observation_log_vex_remediations = data.vex_remediations;
56+
}
4057
}
4158

4259
httpClient(window.__RUNTIME_CONFIG__.API_BASE_URL + "/observation_logs/" + observation_log.id + "/approval/", {
@@ -98,6 +115,36 @@ const AssessmentApproval = ({ observation_log }: AssessmentApprovalProps) => {
98115
maxLength={4096}
99116
/>
100117
)}
118+
{decision == ASSESSMENT_STATUS_APPROVED_WITH_EDITS &&
119+
justificationEnabled &&
120+
settings_vex_justification_style() === VEX_JUSTIFICATION_TYPE_CSAF_OPENVEX && (
121+
<AutocompleteInputWide
122+
source="vex_justification"
123+
label="VEX justification"
124+
choices={OBSERVATION_VEX_JUSTIFICATION_CHOICES}
125+
/>
126+
)}
127+
{decision == ASSESSMENT_STATUS_APPROVED_WITH_EDITS &&
128+
justificationEnabled &&
129+
settings_vex_justification_style() === VEX_JUSTIFICATION_TYPE_CYCLONEDX && (
130+
<AutocompleteInputWide
131+
source="vex_justification"
132+
label="VEX justification"
133+
choices={OBSERVATION_CYCLONEDX_VEX_JUSTIFICATION_CHOICES}
134+
/>
135+
)}
136+
{decision == ASSESSMENT_STATUS_APPROVED_WITH_EDITS && remediationsEnabled && (
137+
<ArrayInput source="vex_remediations" defaultValue={""} label="VEX remediations">
138+
<SimpleFormIterator disableReordering inline>
139+
<AutocompleteInputMedium
140+
source="category"
141+
label=""
142+
choices={OBSERVATION_VEX_REMEDIATION_CATEGORY_CHOICES}
143+
/>
144+
<TextInputWide source="text" multiline={true} minRows={3} />
145+
</SimpleFormIterator>
146+
</ArrayInput>
147+
)}
101148
</SimpleForm>
102149
</DialogContent>
103150
</Dialog>

0 commit comments

Comments
 (0)