diff --git a/backend/app_tests/api/test_api_compliance_assessments.py b/backend/app_tests/api/test_api_compliance_assessments.py index 992fc5b76..780e71346 100644 --- a/backend/app_tests/api/test_api_compliance_assessments.py +++ b/backend/app_tests/api/test_api_compliance_assessments.py @@ -129,6 +129,7 @@ def test_get_compliance_assessments(self, test): "id": str(Framework.objects.all()[0].id), "str": str(Framework.objects.all()[0]), "implementation_groups_definition": None, + "reference_controls": [], "min_score": 1, "max_score": 4, "ref_id": str(Framework.objects.all()[0].ref_id), @@ -168,6 +169,7 @@ def test_create_compliance_assessments(self, test): "id": str(Framework.objects.all()[0].id), "str": str(Framework.objects.all()[0]), "implementation_groups_definition": None, + "reference_controls": [], "min_score": Framework.objects.all()[0].min_score, "max_score": Framework.objects.all()[0].max_score, "ref_id": str(Framework.objects.all()[0].ref_id), @@ -219,6 +221,7 @@ def test_update_compliance_assessments(self, test): "id": str(Framework.objects.all()[0].id), "str": str(Framework.objects.all()[0]), "implementation_groups_definition": None, + "reference_controls": [], "min_score": Framework.objects.all()[0].min_score, "max_score": Framework.objects.all()[0].max_score, "ref_id": str(Framework.objects.all()[0].ref_id), diff --git a/backend/core/models.py b/backend/core/models.py index b2ca68536..1b5267133 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -9,19 +9,6 @@ from django.apps import apps from django.contrib.auth import get_user_model from django.core import serializers -from django.utils.translation import get_language -from library.helpers import ( - update_translations_in_object, - update_translations_as_string, - update_translations, - get_referential_translation, -) - -import os -import json -import yaml -import re - from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator from django.db import models, transaction @@ -29,11 +16,18 @@ from django.forms.models import model_to_dict from django.urls import reverse from django.utils.html import format_html +from django.utils.translation import get_language from django.utils.translation import gettext_lazy as _ -from iam.models import Folder, FolderMixin, PublishInRootFolderMixin -from library.helpers import update_translations, update_translations_in_object from structlog import get_logger +from iam.models import Folder, FolderMixin, PublishInRootFolderMixin +from library.helpers import ( + get_referential_translation, + update_translations, + update_translations_as_string, + update_translations_in_object, +) + from .base_models import AbstractBaseModel, ETADueDateMixin, NameDescriptionMixin from .utils import camel_case, sha256 from .validators import validate_file_name, validate_file_size @@ -925,6 +919,18 @@ def library_entry(self): return res + @property + def reference_controls(self): + _reference_controls = ReferenceControl.objects.filter( + requirements__framework=self + ).distinct() + reference_controls = [] + for control in _reference_controls: + reference_controls.append( + {"str": control.display_long, "urn": control.urn, "id": control.id} + ) + return reference_controls + def get_requirement_nodes(self): # Prefetch related objects if they exist to reduce database queries. # Adjust prefetch_related paths according to your model relationships. @@ -2080,7 +2086,9 @@ def save(self, *args, **kwargs) -> None: self.scores_definition = self.framework.scores_definition super().save(*args, **kwargs) - def create_requirement_assessments(self, baseline: Self | None = None): + def create_requirement_assessments( + self, baseline: Self | None = None + ) -> list["RequirementAssessment"]: requirements = RequirementNode.objects.filter(framework=self.framework) requirement_assessments = [] for requirement in requirements: @@ -2581,6 +2589,41 @@ def infer_result( return (RequirementAssessment.Result.NON_COMPLIANT, None) return (None, None) + def create_applied_controls_from_suggestions(self) -> list[AppliedControl]: + applied_controls: list[AppliedControl] = [] + for reference_control in self.requirement.reference_controls.all(): + try: + _name = reference_control.name or reference_control.ref_id + applied_control, created = AppliedControl.objects.get_or_create( + name=_name, + folder=self.folder, + reference_control=reference_control, + category=reference_control.category, + description=reference_control.description, + ) + if created: + logger.info( + "Successfully created applied control from reference_control", + applied_control=applied_control, + reference_control=reference_control, + ) + else: + logger.info( + "Applied control already exists, skipping creation and using existing one", + applied_control=applied_control, + reference_control=reference_control, + ) + applied_controls.append(applied_control) + except Exception as e: + logger.error( + "An error occurred while creating applied control from reference control", + reference_control=reference_control, + exc_info=e, + ) + continue + self.applied_controls.set(applied_controls) + return applied_controls + class Meta: verbose_name = _("Requirement assessment") verbose_name_plural = _("Requirement assessments") diff --git a/backend/core/serializers.py b/backend/core/serializers.py index ce8eed8c1..038896e04 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -541,7 +541,14 @@ class ComplianceAssessmentReadSerializer(AssessmentReadSerializer): project = FieldsRelatedField(["id", "folder"]) folder = FieldsRelatedField() framework = FieldsRelatedField( - ["id", "min_score", "max_score", "implementation_groups_definition", "ref_id"] + [ + "id", + "min_score", + "max_score", + "implementation_groups_definition", + "ref_id", + "reference_controls", + ] ) selected_implementation_groups = serializers.ReadOnlyField( source="get_selected_implementation_groups" @@ -559,6 +566,9 @@ class ComplianceAssessmentWriteSerializer(BaseModelSerializer): required=False, allow_null=True, ) + create_applied_controls_from_suggestions = serializers.BooleanField( + write_only=True, required=False, default=False + ) def create(self, validated_data: Any): return super().create(validated_data) diff --git a/backend/core/tests/__init__.py b/backend/core/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/core/tests/fixtures.py b/backend/core/tests/fixtures.py new file mode 100644 index 000000000..d21220d3f --- /dev/null +++ b/backend/core/tests/fixtures.py @@ -0,0 +1,39 @@ +import pytest + +from core.models import ( + Project, + StoredLibrary, +) +from iam.models import Folder + + +@pytest.fixture +def domain_project_fixture(): + folder = Folder.objects.create( + name="test folder", description="test folder description" + ) + project = Project.objects.create(name="test project", folder=folder) + return project + + +@pytest.fixture +def risk_matrix_fixture(): + library = StoredLibrary.objects.filter( + urn="urn:intuitem:risk:library:critical_risk_matrix_5x5" + ).last() + assert library is not None + library.load() + + +@pytest.fixture +def iso27001_csf1_1_frameworks_fixture(): + iso27001_library = StoredLibrary.objects.get( + urn="urn:intuitem:risk:library:iso27001-2022", locale="en" + ) + assert iso27001_library is not None + iso27001_library.load() + csf_1_1_library = StoredLibrary.objects.get( + urn="urn:intuitem:risk:library:nist-csf-1.1", locale="en" + ) + assert csf_1_1_library is not None + csf_1_1_library.load() diff --git a/backend/core/tests/test_models.py b/backend/core/tests/test_models.py index 4cdbb84fa..215fdbb3b 100644 --- a/backend/core/tests/test_models.py +++ b/backend/core/tests/test_models.py @@ -18,7 +18,6 @@ Evidence, RiskAcceptance, Asset, - StoredLibrary, Threat, RiskMatrix, LoadedLibrary, @@ -28,41 +27,12 @@ from django.core.files.uploadedfile import SimpleUploadedFile from iam.models import Folder -User = get_user_model() +from .fixtures import * -SAMPLE_640x480_JPG = BASE_DIR / "app_tests" / "sample_640x480.jpg" +User = get_user_model() -@pytest.fixture -def domain_project_fixture(): - folder = Folder.objects.create( - name="test folder", description="test folder description" - ) - project = Project.objects.create(name="test project", folder=folder) - return project - - -@pytest.fixture -def risk_matrix_fixture(): - library = StoredLibrary.objects.filter( - urn="urn:intuitem:risk:library:critical_risk_matrix_5x5" - ).last() - assert library is not None - library.load() - - -@pytest.fixture -def iso27001_csf1_1_frameworks_fixture(): - iso27001_library = StoredLibrary.objects.get( - urn="urn:intuitem:risk:library:iso27001-2022", locale="en" - ) - assert iso27001_library is not None - iso27001_library.load() - csf_1_1_library = StoredLibrary.objects.get( - urn="urn:intuitem:risk:library:nist-csf-1.1", locale="en" - ) - assert csf_1_1_library is not None - csf_1_1_library.load() +SAMPLE_640x480_JPG = BASE_DIR / "app_tests" / "sample_640x480.jpg" @pytest.mark.django_db diff --git a/backend/core/tests/test_requirement_assessment.py b/backend/core/tests/test_requirement_assessment.py new file mode 100644 index 000000000..ceeb844ad --- /dev/null +++ b/backend/core/tests/test_requirement_assessment.py @@ -0,0 +1,71 @@ +import pytest +from django.contrib.auth import get_user_model +from core.models import ( + StoredLibrary, + Framework, + ComplianceAssessment, + Project, + RequirementAssessment, +) +from iam.models import Folder + +from .fixtures import * + +User = get_user_model() + + +@pytest.fixture +def enisa_5g_scm_framework_fixture(): + enisa_5g_scm_library = StoredLibrary.objects.get( + urn="urn:intuitem:risk:library:enisa-5g-scm-v1.3", locale="en" + ) + assert enisa_5g_scm_library is not None + enisa_5g_scm_library.load() + + +@pytest.mark.django_db +class TestRequirementAssessment: + @pytest.mark.usefixtures("domain_project_fixture", "enisa_5g_scm_framework_fixture") + def test_create_applied_controls_from_suggestions(self): + enisa_5g_scm = Framework.objects.first() + compliance_assessment = ComplianceAssessment.objects.create( + name="test compliance assessment", + framework=enisa_5g_scm, + folder=Folder.objects.filter( + content_type=Folder.ContentType.DOMAIN + ).first(), + project=Project.objects.first(), + ) + + requirement_assessments: list[RequirementAssessment] = ( + compliance_assessment.create_requirement_assessments() + ) + assert requirement_assessments is not None + assert len(requirement_assessments) > 0 + + for requirement_assessment in requirement_assessments: + requirement_assessment.create_applied_controls_from_suggestions() + + if len(requirement_assessment.requirement.reference_controls.all()) == 0: + assert requirement_assessment.applied_controls.all().count() == 0 + return + + assert requirement_assessment.applied_controls.all() is not None + assert len(requirement_assessment.applied_controls.all()) > 0 + for control in requirement_assessment.applied_controls.all(): + assert ( + control.reference_control + in requirement_assessment.requirement.reference_controls.all() + ) + + # NOTE: running create_applied_controls_from_suggestions agani MUST not create + # any new applied control. + + applied_controls_count: int = ( + requirement_assessment.applied_controls.all().count() + ) + requirement_assessment.create_applied_controls_from_suggestions() + assert ( + requirement_assessment.applied_controls.all().count() + == applied_controls_count + ) diff --git a/backend/core/urls.py b/backend/core/urls.py index eb8b57965..75cd024e3 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -90,6 +90,14 @@ ), # NOTE: This has to be placed before the allauth urls, otherwise our ACS implementation will not be used path("accounts/", include("allauth.urls")), path("_allauth/", include("allauth.headless.urls")), + path( + "requirement-assessments//suggestions/applied-controls/", + RequirementAssessmentViewSet.create_suggested_applied_controls, + ), + path( + "compliance-assessments//suggestions/applied-controls/", + ComplianceAssessmentViewSet.create_suggested_applied_controls, + ), ] # Additional modules take precedence over the default modules diff --git a/backend/core/views.py b/backend/core/views.py index 501f584dd..6af9f6117 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1,44 +1,37 @@ import csv -import importlib import mimetypes import re import tempfile import uuid import zipfile -from datetime import datetime +from datetime import date, datetime, timedelta from typing import Any, Tuple from uuid import UUID -from datetime import date, timedelta import django_filters as df -from ciso_assistant.settings import ( - BUILD, - VERSION, -) - -from django.utils.decorators import method_decorator -from django.views.decorators.cache import cache_page -from django.views.decorators.vary import vary_on_cookie, vary_on_headers -from django.core.cache import cache - +from django.conf import settings from django.contrib.auth.models import Permission +from django.core.cache import cache from django.core.files.storage import default_storage from django.db import models from django.forms import ValidationError from django.http import FileResponse, HttpResponse from django.middleware import csrf from django.template.loader import render_to_string +from django.utils.decorators import method_decorator from django.utils.functional import Promise -from django.utils import translation +from django.views.decorators.cache import cache_page +from django.views.decorators.vary import vary_on_cookie from django_filters.rest_framework import DjangoFilterBackend -from iam.models import Folder, RoleAssignment, User, UserGroup from rest_framework import filters, permissions, status, viewsets from rest_framework.decorators import ( action, api_view, permission_classes, + renderer_classes, ) from rest_framework.parsers import FileUploadParser +from rest_framework.renderers import JSONRenderer from rest_framework.request import Request from rest_framework.response import Response from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN @@ -46,21 +39,23 @@ from rest_framework.views import APIView from weasyprint import HTML +from ciso_assistant.settings import ( + BUILD, + VERSION, +) from core.helpers import * from core.models import ( AppliedControl, ComplianceAssessment, RequirementMappingSet, - ReferentialObjectMixin, ) from core.serializers import ComplianceAssessmentReadSerializer from core.utils import RoleCodename, UserGroupCodename +from iam.models import Folder, RoleAssignment, User, UserGroup from .models import * from .serializers import * -from django.conf import settings - User = get_user_model() SHORT_CACHE_TTL = 2 # mn @@ -1661,6 +1656,9 @@ def perform_create(self, serializer): Create RequirementAssessment objects for the newly created ComplianceAssessment """ baseline = serializer.validated_data.pop("baseline", None) + create_applied_controls = serializer.validated_data.pop( + "create_applied_controls_from_suggestions", False + ) instance: ComplianceAssessment = serializer.save() instance.create_requirement_assessments(baseline) if baseline and baseline.framework != instance.framework: @@ -1688,6 +1686,9 @@ def perform_create(self, serializer): ] ) requirement_assessment.save() + if create_applied_controls: + for requirement_assessment in instance.requirement_assessments.all(): + requirement_assessment.create_applied_controls_from_suggestions() @action(detail=False, name="Compliance assessments per status") def per_status(self, request): @@ -1819,6 +1820,22 @@ def donut_data(self, request, pk): compliance_assessment = ComplianceAssessment.objects.get(id=pk) return Response(compliance_assessment.donut_render()) + @staticmethod + @api_view(["POST"]) + @renderer_classes([JSONRenderer]) + def create_suggested_applied_controls(request, pk): + compliance_assessment = ComplianceAssessment.objects.get(id=pk) + if not RoleAssignment.is_access_allowed( + user=request.user, + perm=Permission.objects.get(codename="add_appliedcontrol"), + folder=compliance_assessment.folder, + ): + return Response(status=status.HTTP_403_FORBIDDEN) + requirement_assessments = compliance_assessment.requirement_assessments.all() + for requirement_assessment in requirement_assessments: + requirement_assessment.create_applied_controls_from_suggestions() + return Response(status=status.HTTP_200_OK) + class RequirementAssessmentViewSet(BaseModelViewSet): """ @@ -1909,6 +1926,20 @@ def status(self, request): def result(self, request): return Response(dict(RequirementAssessment.Result.choices)) + @staticmethod + @api_view(["POST"]) + @renderer_classes([JSONRenderer]) + def create_suggested_applied_controls(request, pk): + requirement_assessment = RequirementAssessment.objects.get(id=pk) + if not RoleAssignment.is_access_allowed( + user=request.user, + perm=Permission.objects.get(codename="add_appliedcontrol"), + folder=requirement_assessment.folder, + ): + return Response(status=status.HTTP_403_FORBIDDEN) + requirement_assessment.create_applied_controls_from_suggestions() + return Response(status=status.HTTP_200_OK) + class RequirementMappingSetViewSet(BaseModelViewSet): model = RequirementMappingSet diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 60748f74c..6d71c79a4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -650,6 +650,7 @@ "suggestionColon": "Suggestion:", "annotationColon": "Annotation:", "mapping": "Mapping", + "applyMapping": "Apply mapping", "mappingInference": "Mapping inference", "mappingInferenceTip": "Mapping suggestion is available for this requirement", "additionalInformation": "Additional information", @@ -740,7 +741,13 @@ "noAnswer": "No answer", "successfullyUpdatedClientSettings": "Client settings successfully updated, please refresh the page.", "xRaysEmptyMessage": "You have to create at least one project to use X-rays.", - "ShowAllNodesMessage": "Show all nodes", - "ShowOnlyAssessable": "Only assessable nodes", + "suggestControls": "Suggest controls", + "createAppliedControlsFromSuggestionsHelpText": "Create applied controls from the framework's requirements' suggested reference controls", + "createAppliedControlsFromSuggestionsSuccess": "Applied controls successfully created from the suggested reference controls", + "createAppliedControlsFromSuggestionsError": "An error occured while creating applied controls from the suggested reference controls", + "createAppliedControlsFromSuggestionsConfirmMessage": "{count} applied controls will be created from the suggested reference controls. Do you want to proceed?", + "theFollowingControlsWillBeAddedColon": "The following controls will be added:", + "ShowAllNodesMessage": "Show all", + "ShowOnlyAssessable": "Only assessable", "NoPreviewMessage": "No preview available." } diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index 0a777671b..ead1bfa1b 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -673,7 +673,13 @@ "waitingRiskAcceptances": "Bonjour ! Vous avez actuellement {number} risque{s} en attente d'acceptation. Vous pouvez les retrouver dans l'onglet risque.", "successfullyUpdatedClientSettings": "Paramètres du client mis à jour avec succès. Vous pouvez rafrachir la page.", "xRaysEmptyMessage": "Vous devez créer au moins un projet pour utiliser X-rays.", - "ShowAllNodesMessage": "Tous les noeuds", - "ShowOnlyAssessable": "uniquement les nœuds évaluables", - "NoPreviewMessage": "Aucun aperçu disponible." + "NoPreviewMessage": "Aucun aperçu disponible.", + "suggestControls": "Mesures recommendées", + "createAppliedControlsFromSuggestionsHelpText": "Créer des mesures appliquées à partir des mesures de référence suggérées", + "createAppliedControlsFromSuggestionsSuccess": "Mesures appliquées créées avec succès à partir des suggestions", + "createAppliedControlsFromSuggestionsError": "Une erreur s'est produite lors de la création des mesures appliquées depuis les suggestions", + "createAppliedControlsFromSuggestionsConfirmMessage": "{count} mesures appliquées seront créées à partir des suggestions. Voulez-vous continuer ?", + "theFollowingControlsWillBeAddedColon": "Les mesures suivantes seront appliquées :", + "ShowAllNodesMessage": "Tous afficher", + "ShowOnlyAssessable": "uniquement évaluables" } diff --git a/frontend/src/lib/components/Forms/ModelForm/ComplianceAssessmentForm.svelte b/frontend/src/lib/components/Forms/ModelForm/ComplianceAssessmentForm.svelte index 5d3fd22ae..6b77f02c5 100644 --- a/frontend/src/lib/components/Forms/ModelForm/ComplianceAssessmentForm.svelte +++ b/frontend/src/lib/components/Forms/ModelForm/ComplianceAssessmentForm.svelte @@ -7,6 +7,7 @@ import type { SuperValidated } from 'sveltekit-superforms'; import type { ModelInfo, CacheLock } from '$lib/utils/types'; import * as m from '$paraglide/messages.js'; + import Checkbox from '../Checkbox.svelte'; export let form: SuperValidated; export let model: ModelInfo; @@ -130,3 +131,13 @@ cacheLock={cacheLocks['observation']} bind:cachedValue={formDataCache['observation']} /> +{#if context === 'create'} + +{/if} diff --git a/frontend/src/lib/components/List/List.svelte b/frontend/src/lib/components/List/List.svelte new file mode 100644 index 000000000..5d28a60ed --- /dev/null +++ b/frontend/src/lib/components/List/List.svelte @@ -0,0 +1,26 @@ + + +
+ {#if message} +

{message}

+ {/if} +
    + {#each items as item} + {#if item instanceof Array} +
  • + +
  • + {:else if typeof item === 'object' && Object.hasOwn(item, 'str')} +
  • {item.str}
  • + {:else} +
  • {item}
  • + {/if} + {/each} +
+
diff --git a/frontend/src/lib/components/Modals/ConfirmModal.svelte b/frontend/src/lib/components/Modals/ConfirmModal.svelte index 788a68b33..3ba8f0f38 100644 --- a/frontend/src/lib/components/Modals/ConfirmModal.svelte +++ b/frontend/src/lib/components/Modals/ConfirmModal.svelte @@ -17,9 +17,17 @@ export let URLModel: urlModel; export let id: string; export let formAction: string; + export let bodyComponent: ComponentType | undefined; + export let bodyProps: Record = {}; + import { superForm } from 'sveltekit-superforms'; - const { form /*, message*/, enhance } = superForm(_form); + import SuperForm from '$lib/components/Forms/Form.svelte'; + + const { form } = superForm(_form, { + dataType: 'json', + id: `confirm-modal-form-${crypto.randomUUID()}` + }); // Base Classes const cBase = 'card p-4 w-modal shadow-xl space-y-4'; @@ -27,6 +35,7 @@ const cForm = 'p-4 space-y-4 rounded-container-token'; import SuperDebug from 'sveltekit-superforms'; + import type { ComponentType } from 'svelte'; export let debug = false; @@ -34,16 +43,21 @@ {#if !$page.data.user.is_third_party}
-

- {m.associatedRequirements()} +
+ {m.associatedRequirements()} {assessableNodesCount(treeViewNodes)} -

- -
- {#if $displayOnlyAssessableNodes} -

{m.ShowAllNodesMessage()}

- {:else} -

{m.ShowAllNodesMessage()}

- {/if} - ($displayOnlyAssessableNodes = !$displayOnlyAssessableNodes)} - > +
{#if $displayOnlyAssessableNodes} -

{m.ShowOnlyAssessable()}

+

{m.ShowAllNodesMessage()}

{:else} -

{m.ShowOnlyAssessable()}

+

{m.ShowAllNodesMessage()}

{/if} - + ($displayOnlyAssessableNodes = !$displayOnlyAssessableNodes)} + > + {#if $displayOnlyAssessableNodes} +

{m.ShowOnlyAssessable()}

+ {:else} +

{m.ShowOnlyAssessable()}

+ {/if} +
+
+

{m.mappingInferenceTip()}

diff --git a/frontend/src/routes/(app)/(third-party)/compliance-assessments/[id=uuid]/suggestions/applied-controls/+server.ts b/frontend/src/routes/(app)/(third-party)/compliance-assessments/[id=uuid]/suggestions/applied-controls/+server.ts new file mode 100644 index 000000000..26851da6a --- /dev/null +++ b/frontend/src/routes/(app)/(third-party)/compliance-assessments/[id=uuid]/suggestions/applied-controls/+server.ts @@ -0,0 +1,28 @@ +import { BASE_API_URL } from '$lib/utils/constants'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const requestInitOptions: RequestInit = { + method: 'POST' + }; + + const endpoint = `${BASE_API_URL}/compliance-assessments/${event.params.id}/suggestions/applied-controls/`; + const res = await event.fetch(endpoint, requestInitOptions); + + if (!res.ok) { + const response = await res.json(); + console.error(response); + return new Response(JSON.stringify(response), { + status: res.status, + headers: { + 'Content-Type': 'application/json' + } + }); + } + + return new Response(null, { + headers: { + 'Content-Type': 'application/json' + } + }); +};