diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml index 829891c61..448f86a45 100644 --- a/.github/workflows/functional-tests.yml +++ b/.github/workflows/functional-tests.yml @@ -27,15 +27,13 @@ jobs: options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 mailhog: image: mailhog/mailhog - ports: [ - "1025:1025", - "8025:8025" - ] + ports: ["1025:1025", "8025:8025"] strategy: max-parallel: 4 matrix: python-version: ["3.11"] + playwright-browser: ["chromium", "firefox"] steps: - uses: actions/checkout@v4 @@ -54,10 +52,12 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Install dependencies working-directory: ${{ env.working-directory }} - run: npm ci - - name: Install Playwright Browsers + run: | + npm install + npm ci + - name: Install Playwright browser ${{ matrix.playwright-browser }} working-directory: ${{ env.working-directory }} - run: npx playwright install --with-deps + run: npx playwright install --with-deps ${{ matrix.playwright-browser }} - name: Create frontend environment variables file working-directory: ${{ env.working-directory }} run: | @@ -93,14 +93,14 @@ jobs: export $(grep -v '^#' .env | xargs) python manage.py createsuperuser --noinput nohup python manage.py runserver & - - name: Run tests + - name: Run tests with browser ${{ matrix.playwright-browser }} working-directory: ${{ env.working-directory }} - run: npx playwright test + run: npx playwright test --project=${{ matrix.playwright-browser }} - uses: actions/upload-artifact@v4 if: always() with: name: functional-tests-report path: | - ${{ env.working-directory }}/tests/results/ - ${{ env.working-directory }}/tests/reports/ + ${{ env.working-directory }}/tests/${{ matrix.playwright-browser }}results/ + ${{ env.working-directory }}/tests/${{ matrix.playwright-browser }}reports/ retention-days: 5 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dbe741ab8..2ea348010 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,11 +9,12 @@ repos: rev: v0.4.1 hooks: # Run the linter. - - id: ruff - args: [--fix backend] + # - id: ruff + # args: [--fix, backend] # Run the formatter. - id: ruff-format args: [backend] + exclude: (migrations) - repo: local hooks: - id: format-frontend diff --git a/README.md b/README.md index cbc26c5c9..03fc4741f 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ Check out the online documentation on https://intuitem.gitbook.io/ciso-assistant 1. ISO 27001:2022, version Française 🇫🇷🌐 2. PGSSI-S (Politique Générale de Sécurité des Systèmes d'Information de Santé) 🇫🇷 +3. ANSSI : Recommandations de configuration d'un système GNU/Linux 🇫🇷
@@ -261,6 +262,10 @@ export DJANGO_SECRET_KEY=... # Logging configuration export LOG_LEVEL=INFO # optional, default value is INFO. Available options: DEBUG, INFO, WARNING, ERROR, CRITICAL export LOG_FORMAT=plain # optional, default value is plain. Available options: json, plain + +# Authentication options +export AUTH_TOKEN_TTL=900 # optional, default value is 900 seconds (15 minutes). It defines the time to live of the authentication token +export AUTH_TOKEN_AUTO_REFRESH=True # optional, default value is True. It defines if the token TTL should be refreshed automatically after each request authenticated with the token ``` 3. Choose the tool of your choice, either python-venv or virtualenv. For example: @@ -413,7 +418,7 @@ Set DJANGO_DEBUG=False for security reason. - [Django](https://www.djangoproject.com/) - Python Web Development Framework - [SvelteKit](https://kit.svelte.dev/) - Frontend Framework -- [eCharts](https://echarts.apache.org) - Charting library +- [eCharts](https://echarts.apache.org) - Charting library - [Gunicorn](https://gunicorn.org/) - Python WSGI HTTP Server for UNIX - [Caddy](https://caddyserver.com) - The coolest reverse Proxy - [Gitbook](https://www.gitbook.com) - Documentation platform diff --git a/backend/app_tests/api/test_api_libraries.py b/backend/app_tests/api/test_api_libraries.py index 2076ca19d..58a09b591 100644 --- a/backend/app_tests/api/test_api_libraries.py +++ b/backend/app_tests/api/test_api_libraries.py @@ -1,6 +1,8 @@ +import json import pytest from rest_framework.test import APIClient -from core.models import Framework +from app_tests.test_vars import TEST_FRAMEWORK_URN, TEST_RISK_MATRIX_URN +from core.models import Framework, StoredLibrary from core.models import RiskMatrix from iam.models import Folder from rest_framework import status @@ -17,7 +19,7 @@ class TestLibrariesUnauthenticated: def test_get_libraries(self): """test to get libraries from the API without authentication""" - EndpointTestsQueries.get_object(self.client, "Libraries") + EndpointTestsQueries.get_object(self.client, "Stored libraries") def test_import_frameworks(self): """test to import libraries with the API without authentication""" @@ -50,7 +52,7 @@ def test_get_libraries(self, test): """test to get libraries from the API with authentication""" EndpointTestsQueries.Auth.get_object( - test.client, "Libraries", base_count=-1, user_group=test.user_group + test.client, "Stored libraries", base_count=-1, user_group=test.user_group ) def test_import_frameworks(self, test): @@ -58,13 +60,19 @@ def test_import_frameworks(self, test): # Uses the API endpoint to get library details with the admin client lib_detail_response = test.admin_client.get( - EndpointTestsUtils.get_object_urn("Framework") - ).json()["objects"]["framework"] + EndpointTestsUtils.get_stored_library_content( + test.client, TEST_FRAMEWORK_URN + ) + ) + lib_detail_response = lib_detail_response.content + lib_detail_response = json.loads(lib_detail_response) + lib_detail_response = json.loads(lib_detail_response) + lib_detail_response = lib_detail_response["framework"] - # Asserts that the library is not already imported + # Asserts that the library is not already loaded assert ( Framework.objects.all().count() == 0 - ), "libraries are already imported in the database" + ), "libraries are already loaded in the database" EndpointTestsQueries.Auth.get_object( test.client, "Frameworks", @@ -78,12 +86,12 @@ def test_import_frameworks(self, test): assert Framework.objects.all().count() == ( 1 if not EndpointTestsUtils.expected_request_response( - "add", "library", str(test.folder), test.user_group + "add", "loadedlibrary", str(test.folder), test.user_group )[0] else 0 ), "Frameworks are not correctly imported in the database" - # Uses the API endpoint to assert that the library was properly imported + # Uses the API endpoint to assert that the library was properly loaded EndpointTestsQueries.Auth.get_object( test.client, "Frameworks", @@ -96,7 +104,7 @@ def test_import_frameworks(self, test): base_count=1, user_group=test.user_group, fails=EndpointTestsUtils.expected_request_response( - "add", "library", str(test.folder), test.user_group + "add", "loadedlibrary", str(test.folder), test.user_group )[0], ) @@ -121,13 +129,19 @@ def test_import_risk_matrix(self, test): # Uses the API endpoint to get library details with the admin client lib_detail_response = test.admin_client.get( - EndpointTestsUtils.get_object_urn("Risk matrix") - ).json()["objects"]["risk_matrix"][0] + EndpointTestsUtils.get_stored_library_content( + test.client, TEST_RISK_MATRIX_URN + ) + ) + lib_detail_response = lib_detail_response.content + lib_detail_response = json.loads(lib_detail_response) + lib_detail_response = json.loads(lib_detail_response) + lib_detail_response = lib_detail_response["risk_matrix"][0] - # Asserts that the library is not already imported + # Asserts that the library is not already loaded assert ( RiskMatrix.objects.all().count() == 0 - ), "libraries are already imported in the database" + ), "libraries are already loaded in the database" EndpointTestsQueries.Auth.get_object( test.client, "Risk matrices", user_group=test.user_group ) @@ -139,12 +153,12 @@ def test_import_risk_matrix(self, test): assert RiskMatrix.objects.all().count() == ( 1 if not EndpointTestsUtils.expected_request_response( - "add", "library", str(test.folder), test.user_group + "add", "loadedlibrary", str(test.folder), test.user_group )[0] else 0 ), "Risk matrices are not correctly imported in the database" - # Uses the API endpoint to assert that the library was properly imported + # Uses the API endpoint to assert that the library was properly loaded EndpointTestsQueries.Auth.get_object( test.client, "Risk matrices", @@ -158,7 +172,7 @@ def test_import_risk_matrix(self, test): base_count=1, user_group=test.user_group, fails=EndpointTestsUtils.expected_request_response( - "add", "library", str(test.folder), test.user_group + "add", "loadedlibrary", str(test.folder), test.user_group )[0], ) diff --git a/backend/app_tests/api/test_api_requirement_nodes.py b/backend/app_tests/api/test_api_requirement_nodes.py index 5d0ac84fe..19bfaf76c 100644 --- a/backend/app_tests/api/test_api_requirement_nodes.py +++ b/backend/app_tests/api/test_api_requirement_nodes.py @@ -1,6 +1,7 @@ import pytest from rest_framework.test import APIClient -from core.models import RequirementNode, Framework +from app_tests.test_vars import TEST_FRAMEWORK_URN +from core.models import RequirementNode, Framework, StoredLibrary from iam.models import Folder from test_utils import EndpointTestsQueries, EndpointTestsUtils @@ -76,7 +77,9 @@ def test_import_requirement_nodes(self, test): test.client, "Requirement nodes", EndpointTestsUtils.get_endpoint_url("Requirement nodes"), - EndpointTestsUtils.get_object_urn("Framework"), + EndpointTestsUtils.get_stored_library_content( + test.client, TEST_FRAMEWORK_URN + ), [ "name", "description", diff --git a/backend/app_tests/api/test_utils.py b/backend/app_tests/api/test_utils.py index 5a6c6e5f6..0d8ba48ab 100644 --- a/backend/app_tests/api/test_utils.py +++ b/backend/app_tests/api/test_utils.py @@ -1,3 +1,4 @@ +from django.db import models from knox.auth import AuthToken import pytest import json @@ -6,6 +7,7 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from core.models import StoredLibrary from test_vars import * @@ -25,7 +27,17 @@ def get_object_urn(object_name: str, resolved: bool = True): urn_varname = format_urn(object_name) urn = get_var(urn_varname) - return f"{reverse(LIBRARIES_ENDPOINT)}{urn}/" if resolved else eval(urn) + return f"{reverse(STORED_LIBRARIES_ENDPOINT)}{urn}/" if resolved else eval(urn) + + def get_referential_object_url_from_urn( + authenticated_client, urn: str, model: models.Model = StoredLibrary + ): + """Get the object URL from the URN""" + return f"{reverse(STORED_LIBRARIES_ENDPOINT)}{urn}/" + + def get_stored_library_content(authenticated_client, urn: str) -> str: + """Return an URL to fetch the content of a stored library""" + return f"{reverse(STORED_LIBRARIES_ENDPOINT)}{urn}/content/" @pytest.mark.django_db def get_test_client_and_folder( @@ -958,7 +970,7 @@ def import_object( user_perm_expected_status, user_perm_reason, ) = EndpointTestsUtils.expected_request_response( - "add", "library", scope, user_group, expected_status + "add", "loadedlibrary", scope, user_group, expected_status ) url = urn or EndpointTestsUtils.get_object_urn(verbose_name) @@ -1014,9 +1026,12 @@ def compare_results( reference.status_code == status.HTTP_200_OK ), "reference endpoint is not accessible" - for object in reference.json()["objects"]["framework"][ - object_name.lower().replace(" ", "_") - ][:count]: + content = json.loads(reference.content) + content = json.loads(content) + + for object in content["framework"][object_name.lower().replace(" ", "_")][ + :count + ]: comparelist = authenticated_client.get(compare_url) compare = dict() assert ( diff --git a/backend/app_tests/conftest.py b/backend/app_tests/conftest.py index d54672292..8cf15f291 100644 --- a/backend/app_tests/conftest.py +++ b/backend/app_tests/conftest.py @@ -15,7 +15,7 @@ def __init__(self, *args, **kwargs): @pytest.fixture def app_config(): - startup() + startup(sender=None, **{}) @pytest.fixture diff --git a/backend/app_tests/test_vars.py b/backend/app_tests/test_vars.py index fe9384a92..4450f18ed 100644 --- a/backend/app_tests/test_vars.py +++ b/backend/app_tests/test_vars.py @@ -13,7 +13,7 @@ EVIDENCES_ENDPOINT = "evidences-list" FOLDERS_ENDPOINT = "folders-list" FRAMEWORKS_ENDPOINT = "frameworks-list" -LIBRARIES_ENDPOINT = "libraries-list" +STORED_LIBRARIES_ENDPOINT = "stored-libraries-list" RISK_MATRICES_ENDPOINT = "risk-matrices-list" PROJECTS_ENDPOINT = "projects-list" REQUIREMENT_ASSESSMENTS_ENDPOINT = "requirement-assessments-list" @@ -95,7 +95,7 @@ def get_var(varname: str) -> Any: def get_singular_name(plural_name: str) -> str: exceptions = { - "Libraries": "Library", + "Stored libraries": "Stored library", "Risk matrices": "Risk matrix", "Policies": "Policy", } diff --git a/backend/cal/forms.py b/backend/cal/forms.py deleted file mode 100644 index a0333fe71..000000000 --- a/backend/cal/forms.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.forms import ModelForm, DateInput -from cal.models import Event - - -class EventForm(ModelForm): - class Meta: - model = Event - # datetime-local is a HTML5 input type, format to make date time show on fields - widgets = { - "start_time": DateInput( - attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M" - ), - "end_time": DateInput( - attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M" - ), - } - fields = "__all__" - - def __init__(self, *args, **kwargs): - super(EventForm, self).__init__(*args, **kwargs) - # input_formats parses HTML5 datetime-local input to datetime field - self.fields["start_time"].input_formats = ("%Y-%m-%dT%H:%M",) - self.fields["end_time"].input_formats = ("%Y-%m-%dT%H:%M",) diff --git a/backend/ciso_assistant/settings.py b/backend/ciso_assistant/settings.py index 1d6a8904a..10aed45b1 100644 --- a/backend/ciso_assistant/settings.py +++ b/backend/ciso_assistant/settings.py @@ -280,7 +280,7 @@ def set_ciso_assistant_url(_, __, event_dict): # SQLIte file can be changed, useful for tests SQLITE_FILE = os.environ.get("SQLITE_FILE", BASE_DIR / "db/ciso-assistant.sqlite3") - +LIBRARIES_PATH = library_path = BASE_DIR / "library/libraries" if "POSTGRES_NAME" in os.environ: DATABASES = { diff --git a/backend/core/apps.py b/backend/core/apps.py index 86790cd31..b90f7359c 100644 --- a/backend/core/apps.py +++ b/backend/core/apps.py @@ -2,6 +2,8 @@ from django.db.models.signals import post_migrate from ciso_assistant.settings import CISO_ASSISTANT_SUPERUSER_EMAIL import os +from django.core.management import call_command + READER_PERMISSIONS_LIST = [ "view_project", @@ -21,7 +23,8 @@ "view_requirementnode", "view_evidence", "view_framework", - "view_library", + "view_loadedlibrary", + "view_storedlibrary", "view_user", ] @@ -44,7 +47,8 @@ "view_requirementnode", "view_evidence", "view_framework", - "view_library", + "view_storedlibrary", + "view_loadedlibrary", "view_user", ] @@ -97,7 +101,8 @@ "view_riskmatrix", "view_requirementnode", "view_framework", - "view_library", + "view_storedlibrary", + "view_loadedlibrary", "view_user", ] @@ -155,7 +160,8 @@ "delete_evidence", "view_requirementnode", "view_framework", - "view_library", + "view_storedlibrary", + "view_loadedlibrary", "view_user", ] @@ -231,15 +237,18 @@ "view_framework", "delete_framework", "view_requirementnode", - "view_library", - "add_library", - "delete_library", + "view_storedlibrary", + "add_storedlibrary", + "delete_storedlibrary", + "view_loadedlibrary", + "add_loadedlibrary", + "delete_loadedlibrary", "backup", "restore", ] -def startup(**kwargs): +def startup(sender: AppConfig, **kwargs): """ Implement CISO Assistant 1.0 default Roles and User Groups during migrate This makes sure root folder and global groups are defined before any other object is created @@ -346,6 +355,8 @@ def startup(**kwargs): except Exception as e: print(e) # NOTE: Add this exception in the logger + call_command("storelibraries") + class CoreConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" diff --git a/backend/core/base_models.py b/backend/core/base_models.py index aae66186f..cef56cd72 100644 --- a/backend/core/base_models.py +++ b/backend/core/base_models.py @@ -8,7 +8,7 @@ class AbstractBaseModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) - updated_at = models.DateTimeField(auto_now=True, verbose_name=_("UpdatedÒ at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at")) is_published = models.BooleanField(_("published"), default=False) class Meta: diff --git a/backend/core/filters.py b/backend/core/filters.py index f1b17cdc2..99f7d25c7 100644 --- a/backend/core/filters.py +++ b/backend/core/filters.py @@ -22,8 +22,6 @@ class GenericFilterSet(FilterSet): def __init__(self, data=None, queryset=None, *, request=None, prefix=None): super().__init__(data, queryset, request=request, prefix=prefix) - # for f in self.filters.items(): - # print(f[0], f[1].field.widget) class GenericOrderingFilter(OrderingFilter): diff --git a/backend/core/forms.py b/backend/core/forms.py deleted file mode 100644 index 3c9ba9736..000000000 --- a/backend/core/forms.py +++ /dev/null @@ -1,189 +0,0 @@ -from django.forms import ( - CheckboxInput, - DateInput, - DateTimeInput, - EmailInput, - HiddenInput, - ModelForm, - NullBooleanSelect, - NumberInput, - PasswordInput, - Select, - SelectMultiple, - TextInput, - Textarea, - TimeInput, - URLInput, - CheckboxSelectMultiple, - ValidationError, -) -from django.contrib.auth.forms import SetPasswordForm -from django import forms -from .models import * -from django.utils.translation import gettext_lazy as _ -from django.utils.html import escape -from django.contrib.auth import get_user_model - -User = get_user_model() - - -class LinkCleanMixin: - """ - Prevent code injection in link field - """ - - def clean_link(self): - """ - Method to check if a link is valid - """ - link = self.cleaned_data.get("link") - if link: - link = escape(link) - if not link.startswith(("https://", "ftps://")): - raise ValidationError(_("Invalid link")) - return link - - -class SearchableCheckboxSelectMultiple(CheckboxSelectMultiple): - """ - A searchable checkbox select multiple widget. - - Widget attributes (in addition to the standard ones): - - wrapper_class: class for the wrapper div - - searchbar_class: class for the searchbar - """ - - template_name = "forms/widgets/select_multiple.html" - recommended_applied_controls = None - - def __init__(self, recommended_applied_controls=None, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.recommended_applied_controls = recommended_applied_controls - - def get_context(self, name, value, attrs): - context = super().get_context(name, value, attrs) - if self.recommended_applied_controls: - context["recommended_applied_controls"] = self.recommended_applied_controls - return context - - -class SearchableSelect(Select): - template_name = "forms/widgets/searchable_select.html" - option_template_name = "forms/widgets/select_option.html" - recommended_reference_controls = None - - def __init__(self, recommended_reference_controls=None, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.recommended_reference_controls = recommended_reference_controls - self.id = f"searchable-select-{id(self)}" - - def get_context(self, name, value, attrs): - context = super().get_context(name, value, attrs) - if self.recommended_reference_controls: - context["recommended_reference_controls"] = ( - self.recommended_reference_controls - ) - return context - - -class DefaultDateInput(DateInput): - input_type = "date" - - -class StyledModelForm(ModelForm): - def default_if_one(self, field_name): - field = self.fields[field_name] - if not hasattr(field, "_queryset"): - return - if field._queryset and len(field._queryset) == 1: - field.widget.attrs["disabled"] = True - field.widget.attrs["class"] += " disabled:opacity-50" - field.initial = field.queryset[0] - - def default_if_one_all(self): - for fname, f in self.fields.items(): - self.default_if_one(fname) - - def __init__(self, *args, **kwargs): - super(__class__, self).__init__(*args, **kwargs) - text_inputs = ( - TextInput, - NumberInput, - EmailInput, - URLInput, - PasswordInput, - HiddenInput, - DefaultDateInput, - DateInput, - DateTimeInput, - TimeInput, - ) - select_inputs = (Select, SelectMultiple, NullBooleanSelect) - for fname, f in self.fields.items(): - input_type = f.widget.__class__ - if self.Meta.model: - model_name = str(self.Meta.model).split(".")[-1].strip("'>").lower() - if input_type in text_inputs: - f.widget.attrs["id"] = ( - f"id_{model_name}_{fname}" if model_name else f"id_{fname}" - ) - f.widget.attrs["class"] = ( - "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" - ) - if input_type in select_inputs: - f.widget.attrs["id"] = f"id_{model_name}_{fname}" - f.widget.attrs["autocomplete"] = ( - "off" # workaround for Firefox behavior: https://stackoverflow.com/questions/4831848/firefox-ignores-option-selected-selected - ) - f.widget.attrs["class"] = ( - "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 disabled:opacity-50" - ) - if input_type == Textarea: - f.widget.attrs["class"] = ( - "block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500" - ) - if input_type == CheckboxInput: - f.widget.attrs["id"] = f"id_{model_name}_{fname}" - f.widget.attrs["class"] = ( - "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500" - ) - if input_type == DefaultDateInput: - f.widget.attrs["id"] = f"id_{model_name}_{fname}" - f.widget.attrs["class"] = ( - "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" - ) - if input_type == Select: - self.default_if_one(fname) - - -class ResetForm(forms.Form): - email = forms.EmailField( - label=_("Email"), - widget=forms.TextInput( - attrs={ - "class": "my-2 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" - } - ), - ) - - -class ResetConfirmForm(SetPasswordForm): - def __init__(self, *args, **kwargs): - style = "my-2 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" - super(__class__, self).__init__(*args, **kwargs) - for password in self.fields.items(): - password[1].widget.attrs["class"] = style - - -class FirstConnexionConfirmForm(SetPasswordForm): - def __init__(self, *args, **kwargs): - style = "my-2 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" - super(__class__, self).__init__(*args, **kwargs) - for password in self.fields.items(): - password[1].widget.attrs["class"] = style - self.fields["terms_service"].widget.attrs["class"] = ( - "ml-2 rounded border-gray-300 shadow-sm focus:border-indigo-600 focus:ring focus:ring-indigo-500 focus:ring-opacity-50 text-indigo-500" - ) - self.fields["terms_service"].widget.attrs["id"] = "terms_service" - - terms_service = forms.BooleanField(label=_("terms and conditions of use")) diff --git a/backend/core/helpers.py b/backend/core/helpers.py index c71476bb1..3e515bb10 100644 --- a/backend/core/helpers.py +++ b/backend/core/helpers.py @@ -82,9 +82,8 @@ def measures_to_review(user: User): ) measures = ( AppliedControl.objects.filter(id__in=object_ids_view) - .filter(eta__lte=date.today() + timedelta(days=30)) - .exclude(status__iexact="done") - .order_by("eta") + .filter(expiry_date__lte=date.today() + timedelta(days=30)) + .order_by("expiry_date") ) return measures @@ -255,22 +254,24 @@ def get_sorted_requirement_nodes_rec( "parent_urn": node.parent_urn, "ref_id": node.ref_id, "name": node.name, - "implementation_groups": node.implementation_groups - if node.implementation_groups - else None, + "implementation_groups": ( + node.implementation_groups if node.implementation_groups else None + ), "ra_id": str(req_as.id) if requirements_assessed else None, "status": req_as.status if requirements_assessed else None, "is_scored": req_as.is_scored if requirements_assessed else None, "score": req_as.score if requirements_assessed else None, - "max_score": req_as.compliance_assessment.framework.max_score - if requirements_assessed - else None, - "status_display": req_as.get_status_display() - if requirements_assessed - else None, - "status_i18n": camel_case(req_as.status) - if requirements_assessed - else None, + "max_score": ( + req_as.compliance_assessment.framework.max_score + if requirements_assessed + else None + ), + "status_display": ( + req_as.get_status_display() if requirements_assessed else None + ), + "status_i18n": ( + camel_case(req_as.status) if requirements_assessed else None + ), "node_content": node.display_long, "style": "node", "assessable": node.assessable, @@ -293,9 +294,11 @@ def get_sorted_requirement_nodes_rec( { "urn": req.urn, "ref_id": req.ref_id, - "implementation_groups": req.implementation_groups - if req.implementation_groups - else None, + "implementation_groups": ( + req.implementation_groups + if req.implementation_groups + else None + ), "name": req.name, "description": req.description, "ra_id": str(req_as.id), @@ -576,7 +579,7 @@ def applied_control_per_cur_risk(user: User): for lvl in get_rating_options(user): cnt = ( AppliedControl.objects.filter(id__in=object_ids_view) - .exclude(status="done") + .exclude(status="active") .filter(risk_scenarios__current_level=lvl[0]) .count() ) @@ -744,15 +747,38 @@ def risks_per_project_groups(user: User): def get_counters(user: User): + print() return { - "domains": Folder.objects.filter( - content_type=Folder.ContentType.DOMAIN - ).count(), - "projects": Project.objects.all().count(), - "applied_controls": AppliedControl.objects.all().count(), - "risk_assessments": RiskAssessment.objects.all().count(), - "compliance_assessments": ComplianceAssessment.objects.all().count(), - "policies": Policy.objects.all().count(), + "domains": len( + RoleAssignment.get_accessible_object_ids( + Folder.get_root_folder(), user, Folder + )[0] + ), + "projects": len( + RoleAssignment.get_accessible_object_ids( + Folder.get_root_folder(), user, Project + )[0] + ), + "applied_controls": len( + RoleAssignment.get_accessible_object_ids( + Folder.get_root_folder(), user, AppliedControl + )[0] + ), + "risk_assessments": len( + RoleAssignment.get_accessible_object_ids( + Folder.get_root_folder(), user, RiskAssessment + )[0] + ), + "compliance_assessments": len( + RoleAssignment.get_accessible_object_ids( + Folder.get_root_folder(), user, ComplianceAssessment + )[0] + ), + "policies": len( + RoleAssignment.get_accessible_object_ids( + Folder.get_root_folder(), user, Policy + )[0] + ), } @@ -841,12 +867,9 @@ def acceptances_to_review(user: User): acceptances = ( RiskAcceptance.objects.filter(id__in=object_ids_view) .filter(expiry_date__lte=date.today() + timedelta(days=30)) - .order_by("expiry_date") - ) - acceptances |= ( - RiskAcceptance.objects.filter(id__in=object_ids_view) .filter(approver=user) - .filter(state="submitted") + .filter(state__in=["submitted", "accepted"]) + .order_by("expiry_date") ) return acceptances @@ -950,3 +973,35 @@ def compile_risk_assessment_for_composer(user, risk_assessment_list: list): }, "colors": get_risk_color_ordered_list(user, risk_assessment_list), } + + +def threats_count_per_name(user: User): + labels = list() + values = list() + ( + object_ids_view, + _, + _, + ) = RoleAssignment.get_accessible_object_ids(Folder.get_root_folder(), user, Threat) + viewable_scenarios = RoleAssignment.get_accessible_object_ids( + Folder.get_root_folder(), user, RiskScenario + )[0] + + # expected by echarts to send the threats names in labels and the count of each threat in values + + for threat in Threat.objects.filter(id__in=object_ids_view).order_by("name"): + val = ( + RiskScenario.objects.filter(threats=threat) + .filter(id__in=viewable_scenarios) + .count() + ) + if val > 0: + labels.append({"name": threat.name}) + values.append(val) + max_offset = max(values, default=0) # we can add x later on to improve visibility + + # update each label to include the max_offset + for label in labels: + label["max"] = max_offset + + return {"labels": labels, "values": values} diff --git a/backend/core/management/commands/status.py b/backend/core/management/commands/status.py index d62ec767f..7632c3349 100644 --- a/backend/core/management/commands/status.py +++ b/backend/core/management/commands/status.py @@ -9,7 +9,7 @@ class Command(BaseCommand): def handle(self, *args, **kwargs): nb_users = User.objects.all().count() nb_first_login = User.objects.filter(first_login=True).count() - nb_libraries = Library.objects.all().count() + nb_libraries = LoadedLibrary.objects.all().count() nb_domains = Folder.objects.filter(content_type="DO").count() nb_projects = Project.objects.all().count() nb_assets = Asset.objects.all().count() diff --git a/backend/core/migrations/0012_alter_appliedcontrol_updated_at_and_more.py b/backend/core/migrations/0012_alter_appliedcontrol_updated_at_and_more.py new file mode 100644 index 000000000..9b1f352d8 --- /dev/null +++ b/backend/core/migrations/0012_alter_appliedcontrol_updated_at_and_more.py @@ -0,0 +1,353 @@ +# Generated by Django 5.0.4 on 2024-05-03 12:41 +# loadedlibrary updates added manually + +import django.db.models.deletion +import iam.models +import uuid +from django.db import migrations, models + +BUILTIN_LIBRARY_URNS = set( + [ + "urn:intuitem:risk:library:nis2-directive", + "urn:intuitem:risk:library:cmmc-2.0", + "urn:intuitem:risk:library:pcidss-4_0", + "urn:intuitem:risk:library:nist-ssdf-1.1", + "urn:intuitem:risk:library:rgs-v2.0", + "urn:intuitem:risk:library:doc-pol", + "urn:intuitem:risk:library:dora", + "urn:intuitem:risk:library:3cf-v2", + "urn:intuitem:risk:library:owasp-top-10-web", + "urn:intuitem:risk:library:hds-v2023-a", + "urn:ackwa:risk:library:pgssi-s-1.0", + "urn:intuitem:risk:library:gdpr-checklist", + "urn:intuitem:risk:library:anssi-guide-hygiene", + "urn:intuitem:risk:library:iso27001-2022", + "urn:intuitem:risk:library:mitre-attack-v14", + "urn:protocolpaladin:risk:library:matrice-des-risques-critiques-5x5", + "urn:intuitem:risk:library:risk-matrix-3x3-mult", + "urn:intuitem:risk:library:fedramp-rev5", + "urn:intuitem:risk:library:nist-csf-1.1", + "urn:intuitem:risk:library:critical_risk_matrix_3x3", + "urn:intuitem:risk:library:nist-800-171-rev2", + "urn:intuitem:risk:library:ecc-1", + "urn:intuitem:risk:library:secnumcloud-3.2-annexe-2", + "urn:intuitem:risk:library:secnumcloud-3.2", + "urn:intuitem:risk:library:3cf-ed1-v1", + "urn:intuitem:risk:library:fadp", + "urn:intuitem:risk:library:tisax-v6.0.2", + "urn:intuitem:risk:library:owasp-asvs-4.0.3", + "urn:protocolpaladin:risk:library:anssi-recommandations-configuration-systeme-gnu-linux", + "urn:intuitem:risk:library:lpm-oiv-2019", + "urn:intuitem:risk:library:aircyber-v1.5.2", + "urn:intuitem:risk:library:nist-ai-rmf-1.0", + "urn:intuitem:risk:library:dfs-500-2023-11", + "urn:intuitem:risk:library:nist-csf-2.0", + "urn:intuitem:risk:library:anssi-nis-rules", + "urn:intuitem:risk:library:risk-matrix-5x5-sensitive", + "urn:intuitem:risk:library:iso27001-2022-fr", + "urn:intuitem:risk:library:pspf", + "urn:intuitem:risk:library:nist-privacy-1.0", + "urn:intuitem:risk:library:ccb-cff-2023-03-01", + "urn:intuitem:risk:library:cra-proposal-annexes", + "urn:ackwa:risk:library:risk-matrix-4x4-pgssi-s-1.0", + "urn:intuitem:risk:library:essential-eight", + "urn:intuitem:risk:library:nist-sp-800-66-rev2", + "urn:intuitem:risk:library:critical_risk_matrix_5x5", + "urn:protocolpaladin:risk:library:matrice-des-risques-critiques-3x3", + "urn:intuitem:risk:library:nist-sp-800-53-rev5", + "urn:intuitem:risk:library:tiber-eu-2018", + "urn:intuitem:risk:library:anssi-genai-security-recommendations-1.0", + "urn:intuitem:risk:library:soc2-2017", + ] +) + + +def adapt_libraries(apps, schema_editor): + LoadedLibrary = apps.get_model("core", "LoadedLibrary") + for library in LoadedLibrary.objects.all(): + library.builtin = ( + library.urn in BUILTIN_LIBRARY_URNS + ) # There is no perfect way to verify is a loaded custom library is builtin or not + # There is no way to generate the objects_meta dictionary without reading all files from ./backend/library/libraries, but we can generate the missing objects_meta values at the same time we generate the StoredLibrary objects. + + library.objects_meta = { + "frameworks": library.frameworks.count(), + "threats": library.threats.count(), + "reference_controls": library.reference_controls.count(), + "risk_matrix": library.risk_matrices.count(), + } + library.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0011_auto_20240501_1342"), + ("iam", "0003_alter_folder_updated_at_alter_role_updated_at_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="appliedcontrol", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="asset", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="complianceassessment", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="evidence", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="framework", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="project", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="referencecontrol", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="requirementassessment", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="requirementnode", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="riskacceptance", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="riskassessment", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="riskmatrix", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="riskscenario", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterField( + model_name="threat", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.RenameModel("Library", "LoadedLibrary"), + migrations.AlterField( + model_name="loadedlibrary", + name="provider", + field=models.CharField( + blank=True, max_length=200, null=True, verbose_name="Provider" + ), + ), + migrations.AlterField( + model_name="loadedlibrary", + name="urn", + field=models.CharField( + blank=True, max_length=100, null=True, verbose_name="URN" + ), + ), + migrations.AlterField( + model_name="loadedlibrary", + name="dependencies", + field=models.ManyToManyField( + blank=True, to="core.loadedlibrary", verbose_name="Dependencies" + ), + ), + migrations.AddField( + model_name="loadedlibrary", + name="builtin", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="loadedlibrary", + name="objects_meta", + field=models.JSONField(default=dict), + ), + migrations.AlterField( + model_name="loadedlibrary", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + migrations.AlterModelOptions( + name="loadedlibrary", + options={ + "abstract": False, + "unique_together": {("urn", "locale", "version")}, + }, + ), + migrations.AlterField( + model_name="framework", + name="library", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="frameworks", + to="core.loadedlibrary", + ), + ), + migrations.AlterField( + model_name="referencecontrol", + name="library", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="reference_controls", + to="core.loadedlibrary", + ), + ), + migrations.AlterField( + model_name="riskmatrix", + name="library", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="risk_matrices", + to="core.loadedlibrary", + ), + ), + migrations.AlterField( + model_name="threat", + name="library", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="threats", + to="core.loadedlibrary", + ), + ), + migrations.CreateModel( + name="StoredLibrary", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created at"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Updated at"), + ), + ( + "is_published", + models.BooleanField(default=False, verbose_name="published"), + ), + ( + "ref_id", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Reference ID", + ), + ), + ( + "locale", + models.CharField( + default="en", max_length=100, verbose_name="Locale" + ), + ), + ( + "default_locale", + models.BooleanField(default=True, verbose_name="Default locale"), + ), + ( + "provider", + models.CharField( + blank=True, max_length=200, null=True, verbose_name="Provider" + ), + ), + ( + "name", + models.CharField(max_length=200, null=True, verbose_name="Name"), + ), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="Description"), + ), + ( + "annotation", + models.TextField(blank=True, null=True, verbose_name="Annotation"), + ), + ( + "urn", + models.CharField( + blank=True, max_length=100, null=True, verbose_name="URN" + ), + ), + ( + "copyright", + models.CharField( + blank=True, max_length=4096, null=True, verbose_name="Copyright" + ), + ), + ("version", models.IntegerField(verbose_name="Version")), + ( + "packager", + models.CharField( + blank=True, + help_text="Packager of the library", + max_length=100, + null=True, + verbose_name="Packager", + ), + ), + ("builtin", models.BooleanField(default=False)), + ("objects_meta", models.JSONField(default=dict)), + ("dependencies", models.JSONField(null=True)), + ("is_loaded", models.BooleanField(default=False)), + ("hash_checksum", models.CharField(max_length=64)), + ("content", models.TextField()), + ( + "folder", + models.ForeignKey( + default=iam.models.Folder.get_root_folder, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), + ), + ], + options={ + "abstract": False, + "unique_together": {("urn", "locale", "version")}, + }, + ), + migrations.RunPython(adapt_libraries), + ] diff --git a/backend/core/models.py b/backend/core/models.py index 73ff08b8e..87e3cb929 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -1,3 +1,4 @@ +from pathlib import Path from django.apps import apps from django.forms.models import model_to_dict from django.contrib.auth import get_user_model @@ -7,16 +8,17 @@ from .base_models import * from .validators import validate_file_size, validate_file_name -from .utils import camel_case +from .utils import camel_case, sha256 from iam.models import FolderMixin, PublishInRootFolderMixin from django.core import serializers import os import json +import yaml from django.urls import reverse from datetime import date, datetime -from typing import Self +from typing import Union, Self from django.utils.html import format_html from structlog import get_logger @@ -83,18 +85,16 @@ def __str__(self) -> str: return self.display_short -class Library(ReferentialObjectMixin): +class LibraryMixin(ReferentialObjectMixin): + class Meta: + abstract = True + unique_together = [["urn", "locale", "version"]] + + urn = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("URN")) copyright = models.CharField( max_length=4096, null=True, blank=True, verbose_name=_("Copyright") ) version = models.IntegerField(null=False, verbose_name=_("Version")) - provider = models.CharField( - max_length=100, - blank=True, - null=True, - help_text=_("Provider of the library"), - verbose_name=_("Provider"), - ) packager = models.CharField( max_length=100, blank=True, @@ -102,6 +102,108 @@ class Library(ReferentialObjectMixin): help_text=_("Packager of the library"), verbose_name=_("Packager"), ) + builtin = models.BooleanField(default=False) + objects_meta = models.JSONField(default=dict) + dependencies = models.JSONField( + null=True + ) # models.CharField(blank=False,null=True,max_length=16384) + + +class StoredLibrary(LibraryMixin): + is_loaded = models.BooleanField(default=False) + hash_checksum = models.CharField(max_length=64) + content = models.TextField() + + REQUIRED_FIELDS = {"urn", "name", "version", "objects"} + FIELDS_VERIFIERS = {} + HASH_CHECKSUM_SET = set() # For now a library isn't updated if its SHA256 checksum has already been registered in the database. + + @classmethod + def __init_class__(cls): + cls.HASH_CHECKSUM_SET = set( + value["hash_checksum"] for value in cls.objects.values("hash_checksum") + ) + + @classmethod + def store_library_content( + cls, library_content: bytes, builtin: bool = False + ) -> "StoredLibrary | None": + hash_checksum = sha256(library_content) + if hash_checksum in StoredLibrary.HASH_CHECKSUM_SET: + return None # We do not store the library if its hash checksum is in the database. + try: + library_data = yaml.safe_load(library_content) + except yaml.YAMLError as e: + logger.error("Error while loading library content", error=e) + raise e + + missing_fields = StoredLibrary.REQUIRED_FIELDS - set(library_data.keys()) + + if missing_fields: + err = "The following fields are missing : {}".format( + ", ".join(repr(field) for field in missing_fields) + ) + logger.error("Error while loading library content", error=err) + raise ValueError(err) + + urn = library_data["urn"] + locale = library_data.get("locale", "en") + version = int(library_data["version"]) + is_loaded = LoadedLibrary.objects.filter( + urn=urn, locale=locale, version=version + ).exists() + + objects_meta = { + key: (1 if key == "framework" else len(value)) + for key, value in library_data["objects"].items() + } + + dependencies = library_data.get( + "dependencies", [] + ) # I don't want whitespaces in URN anymore nontheless + + library_objects = json.dumps(library_data["objects"]) + return StoredLibrary.objects.create( + name=library_data["name"], + is_published=True, + urn=urn, + locale=locale, + version=version, + ref_id=library_data["ref_id"], + default_locale=False, # We don't care about this value yet. + description=library_data.get("description"), + annotation=library_data.get("annotation"), + copyright=library_data.get("copyright"), + provider=library_data.get("provider"), + packager=library_data.get("packager"), + objects_meta=objects_meta, + dependencies=dependencies, + is_loaded=is_loaded, + builtin=builtin, # We have to add a "builtin: true" line to every builtin library file. + hash_checksum=hash_checksum, + content=library_objects, + ) + + @classmethod + def store_library_file( + cls, fname: Path, builtin: bool = False + ) -> "StoredLibrary | None": + with open(fname, "rb") as f: + library_content = f.read() + return StoredLibrary.store_library_content(library_content, builtin) + + def load(self) -> Union[str, None]: + from library.utils import LibraryImporter + + library_importer = LibraryImporter(self) + error_msg = library_importer.import_library() + if error_msg is None: + self.is_loaded = True + self.save() + return error_msg + + +class LoadedLibrary(LibraryMixin): dependencies = models.ManyToManyField( "self", blank=True, verbose_name=_("Dependencies"), symmetrical=False ) @@ -151,7 +253,7 @@ def reference_count(self) -> int: ) .distinct() .count() - + Library.objects.filter(dependencies=self).distinct().count() + + LoadedLibrary.objects.filter(dependencies=self).distinct().count() ) def delete(self, *args, **kwargs): @@ -159,17 +261,26 @@ def delete(self, *args, **kwargs): raise ValueError( "This library is still referenced by some risk or compliance assessments" ) - dependent_libraries = Library.objects.filter(dependencies=self) + dependent_libraries = LoadedLibrary.objects.filter(dependencies=self) if dependent_libraries: raise ValueError( f"This library is a dependency of {dependent_libraries.count()} other libraries" ) - super(Library, self).delete(*args, **kwargs) + super(LoadedLibrary, self).delete(*args, **kwargs) + stored_library = StoredLibrary.objects.get( + urn=self.urn, locale=self.locale, version=self.version + ) # I don't if it works yet + stored_library.is_loaded = False + stored_library.save() class Threat(ReferentialObjectMixin, PublishInRootFolderMixin): library = models.ForeignKey( - Library, on_delete=models.CASCADE, null=True, blank=True, related_name="threats" + LoadedLibrary, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="threats", ) fields_to_check = ["ref_id", "name"] @@ -203,7 +314,7 @@ class ReferenceControl(ReferentialObjectMixin): ] library = models.ForeignKey( - Library, + LoadedLibrary, on_delete=models.CASCADE, null=True, blank=True, @@ -246,7 +357,7 @@ def __str__(self): class RiskMatrix(ReferentialObjectMixin): library = models.ForeignKey( - Library, + LoadedLibrary, on_delete=models.CASCADE, null=True, blank=True, @@ -336,7 +447,7 @@ class Framework(ReferentialObjectMixin): blank=True, null=True, verbose_name=_("Implementation groups definition") ) library = models.ForeignKey( - Library, + LoadedLibrary, on_delete=models.CASCADE, null=True, blank=True, @@ -938,7 +1049,7 @@ def quality_check(self) -> dict: measures[i]["id"] = json.loads(_measures)[i]["pk"] for mtg in measures: - if not mtg["eta"]: + if not mtg["eta"] and not mtg["status"] == "active": warnings_lst.append( { "msg": _("{} does not have an ETA").format(mtg["name"]), @@ -946,7 +1057,7 @@ def quality_check(self) -> dict: "object": {"name": mtg["name"], "id": mtg["id"]}, } ) - else: + elif mtg["eta"] and not mtg["status"] == "active": if date.today() > datetime.strptime(mtg["eta"], "%Y-%m-%d").date(): errors_lst.append( { @@ -1319,6 +1430,9 @@ def get_selected_implementation_groups(self): if group.get("ref_id") in self.selected_implementation_groups ] + def get_requirement_assessments(self): + return RequirementAssessment.objects.filter(compliance_assessment=self) + def get_requirements_status_count(self): requirements_status_count = [] for st in RequirementAssessment.Status: @@ -1348,11 +1462,6 @@ def get_measures_status_count(self): st, ) ) - print( - "AppliedControl".objects.filter(status=st[0]) - .filter(id__in=measures_list) - .count() - ) return measures_status_count def donut_render(self) -> dict: diff --git a/backend/core/serializers.py b/backend/core/serializers.py index bfdfd6327..f87aba05d 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -163,16 +163,17 @@ class ReferenceControlReadSerializer(ReferenceControlWriteSerializer): library = FieldsRelatedField(["name", "urn"]) -class LibraryReadSerializer(BaseModelSerializer): +"""class LibraryReadSerializer(BaseModelSerializer): class Meta: - model = Library + model = LoadedLibrary fields = "__all__" class LibraryWriteSerializer(BaseModelSerializer): class Meta: - model = Library + model = LoadedLibrary fields = "__all__" +""" class ThreatWriteSerializer(BaseModelSerializer): diff --git a/backend/core/templates/core/action_plan_pdf.html b/backend/core/templates/core/action_plan_pdf.html new file mode 100644 index 000000000..53efae1ba --- /dev/null +++ b/backend/core/templates/core/action_plan_pdf.html @@ -0,0 +1,68 @@ +{% extends 'core/base_pdf.html' %} +{% block content %} +{% load i18n core_extras %} + + + +
+

Action plan

+
+

{% trans "Domain" %}: {{ compliance_assessment.project.folder }}

+

/

+

{% trans "Project" %}: {{ compliance_assessment.project.name }}

+

/

+

{% trans "Audit" %}: {{ compliance_assessment.name }} - {{ compliance_assessment.version }}

+

/

+

{% trans "Framework" %}: {{ compliance_assessment.framework }}

+
+

{% trans "Associated applied controls" %}:

+

{% trans "Separated by status and sorted by eta" %}

+ {% for status, applied_controls in context.items%} + {% for status_color, color in color_map.items %} + {% if status_color == status %} +

{% trans status|title %}:

+ {% endif %} + {% endfor %} +
+
+ + + + + + + + + + + + + + {% for applied_control in applied_controls %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Description" %}{% trans "Category" %}{% trans "ETA" %}{% trans "Expiry date" %}{% trans "Effort" %}{% trans "Matching requirements" %}
{{ applied_control.name }}{{ applied_control.description }}{{ applied_control.category }}{{ applied_control.eta }}{{ applied_control.expiry_date }}{{ applied_control.effort }}{% get_requirements_count applied_control compliance_assessment %}
+

{% trans "No entries found" %}

+
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/backend/core/templates/core/base_pdf.html b/backend/core/templates/core/base_pdf.html index 60517e7fb..9ae07a2b2 100644 --- a/backend/core/templates/core/base_pdf.html +++ b/backend/core/templates/core/base_pdf.html @@ -23,6 +23,9 @@ .flex { display: flex; } +.flex-row { + flex-direction: row; +} .table { display: table; } @@ -234,6 +237,9 @@ .ml-3 { margin-left: 0.75rem; } +.ml-13 { + margin-left: 3.25rem; +} .p-1 { padding: 0.25rem; } @@ -267,6 +273,9 @@ .mt-10 { margin-top: 2.5rem; } +.mt-45{ + margin-top: 11.25rem; +} .justify-center { justify-content: center; diff --git a/backend/core/templates/snippets/ra_data.html b/backend/core/templates/snippets/ra_data.html index 4c26c57b3..ad4ae4e97 100644 --- a/backend/core/templates/snippets/ra_data.html +++ b/backend/core/templates/snippets/ra_data.html @@ -3,13 +3,18 @@
-
-
+
+
{% trans "Risk assessment" %}
-
{{ risk_assessment }}
+
{{ risk_assessment }}

    +
  • {% trans "Risk matrix:" %} +
      +
    • {{ risk_assessment.risk_matrix }}
    • +
    +
  • {% trans "Authors:" %}
      {% for author in risk_assessment.authors.all %} @@ -17,18 +22,24 @@ {% endfor %}
  • -
  • {% trans "Status:" %} {% if risk_assessment.is_draft %} - {% trans "Draft" %} {% else %} {% trans "Ready" %} {% endif %} +
  • {% trans "Reviewers:" %} +
      + {% for author in risk_assessment.reviewers.all %} +
    • {{ author }}
    • + {% endfor %} +
    +
  • +
  • {% trans "Status:" %} + {% if risk_assessment.status %} {{risk_assessment.status}} {% else %} -- {% endif %}
  • +
  • {% trans "ETA:" %} {{ risk_assessment.eta|date }}
  • +
  • {% trans "Due date:" %} {{ risk_assessment.due_date|date }}
  • {% trans "Created at:" %} {{ risk_assessment.created_at|date }}
  • {% trans "Updated at:" %} {{ risk_assessment.updated_at|date }}
-
{% trans "Risk matrix:" %}

{{ risk_assessment.risk_matrix }}

-
-
{% trans "Description" %}:
{% if risk_assessment.description %}{{ risk_assessment.description|linebreaksbr }}{% endif %}
@@ -37,12 +48,12 @@
-
{% trans "Risk scenarios" %} ({{ context|length }})
+

{% trans "Associated Risk scenarios" %} ({{ context|length }}):

{% include 'snippets/ri_list_nested.html' %}
-
-
{% trans "Risk matrix view" %}
+
+
{% trans "Risk matrix view" %}

{% trans "Current" %}

@@ -60,10 +71,11 @@

{% trans "Residual" %}

{% include 'snippets/risk_matrix.html' with data=ri_clusters.residual %} {% endif %}
- -
+
+
+ {% include 'snippets/risk_legend.html' %}
diff --git a/backend/core/templates/snippets/ri_list_nested.html b/backend/core/templates/snippets/ri_list_nested.html index f830aa110..6a2e7254b 100644 --- a/backend/core/templates/snippets/ri_list_nested.html +++ b/backend/core/templates/snippets/ri_list_nested.html @@ -1,7 +1,13 @@ {% load i18n %} {% load static tailwind_tags %} - + + +
{% for scenario in scenarios %} - + @@ -45,9 +51,13 @@
{% trans "ID" %} @@ -30,7 +36,7 @@
{{ scenario.rid }} -

- {% if scenario.threat %}{{ scenario.threat }}{% else %}--{% endif %} -

+ {% for threat in scenario.threats.all %} +

+ {{ threat }} +

+ {% empty %} + -- + {% endfor %}

{{ scenario.existing_controls|linebreaksbr }}

diff --git a/backend/core/templates/snippets/risk_legend.html b/backend/core/templates/snippets/risk_legend.html new file mode 100644 index 000000000..bb4ceed62 --- /dev/null +++ b/backend/core/templates/snippets/risk_legend.html @@ -0,0 +1,21 @@ +{% load i18n core_extras %} + +
+

{% trans "Risk levels" %}

+
+ + {% for risk in risk_matrix.parse_json.risk %} + + + + + + {% endfor %} +
+ + {{ risk.name }} + + {{ risk.description|linebreaksbr }} +
+
+
diff --git a/backend/core/templates/snippets/risk_matrix.html b/backend/core/templates/snippets/risk_matrix.html index 9b4614c90..45ed95bc0 100644 --- a/backend/core/templates/snippets/risk_matrix.html +++ b/backend/core/templates/snippets/risk_matrix.html @@ -41,18 +41,18 @@ {% else %}
-
-