{m.riskAssessment()}
- ... {m.asPDF()} - ... {m.asCSV()} -{m.treatmentPlan()}
- ... {m.asPDF()} - ... {m.asCSV()} -{m.riskAssessment()}
+ ... {m.asPDF()} + ... {m.asCSV()} +{m.treatmentPlan()}
+ ... {m.asPDF()} + ... {m.asCSV()} +diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 869f830c6..ec824c4e5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,7 +2,7 @@ name: Feature Request about: Suggestions for new features and improvements title: "" -labels: "new feature" +labels: "question" assignees: "" --- diff --git a/.github/workflows/docker-build-and-push.yml b/.github/workflows/docker-build-and-push.yml index 21bbd018d..391f11c09 100644 --- a/.github/workflows/docker-build-and-push.yml +++ b/.github/workflows/docker-build-and-push.yml @@ -43,9 +43,19 @@ jobs: cp .meta ./backend/ cp .meta ./backend/ciso_assistant/ + - name: Build and Push Frontend Docker Image + uses: docker/build-push-action@v6 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: true + tags: | + ghcr.io/${{ github.repository }}/frontend:${{ env.VERSION }} + ghcr.io/${{ github.repository }}/frontend:latest + platforms: linux/amd64,linux/arm64,linux/arm64/v8 - name: Build and Push Backend Docker Image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: ./backend file: ./backend/Dockerfile @@ -55,13 +65,3 @@ jobs: ghcr.io/${{ github.repository }}/backend:latest platforms: linux/amd64,linux/arm64,linux/arm64/v8 - - name: Build and Push Frontend Docker Image - uses: docker/build-push-action@v5 - with: - context: ./frontend - file: ./frontend/Dockerfile - push: true - tags: | - ghcr.io/${{ github.repository }}/frontend:${{ env.VERSION }} - ghcr.io/${{ github.repository }}/frontend:latest - platforms: linux/amd64,linux/arm64,linux/arm64/v8 diff --git a/.github/workflows/frontend-coverage.yaml b/.github/workflows/frontend-coverage.yaml index 3df61bc5d..129f34ba3 100644 --- a/.github/workflows/frontend-coverage.yaml +++ b/.github/workflows/frontend-coverage.yaml @@ -26,15 +26,15 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - - name: Install latest npm + - name: Install latest pnpm working-directory: ${{env.working-directory}} run: | - npm install -g npm && - npm --version && - npm list -g --depth 0 + npm install -g pnpm && + pnpm --version && + pnpm list -g --depth 0 - name: Install dependencies working-directory: ${{env.working-directory}} - run: npm ci + run: pnpm i --frozen-lockfile - name: Run coverage working-directory: ${{env.working-directory}} - run: npm run coverage + run: pnpm run coverage diff --git a/.github/workflows/frontend-unit-tests.yml b/.github/workflows/frontend-unit-tests.yml index 1f60c714a..db8a63e48 100644 --- a/.github/workflows/frontend-unit-tests.yml +++ b/.github/workflows/frontend-unit-tests.yml @@ -28,12 +28,12 @@ jobs: - name: Install latest npm working-directory: ${{env.working-directory}} run: | - npm install -g npm && - npm --version && - npm list -g --depth 0 + npm install -g pnpm && + pnpm --version && + pnpm list -g --depth 0 - name: Install dependencies working-directory: ${{env.working-directory}} - run: npm ci + run: pnpm i --frozen-lockfile - name: Run tests working-directory: ${{env.working-directory}} - run: npm run test:ci + run: pnpm run test:ci diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml index 37b8eacb3..a6649621a 100644 --- a/.github/workflows/functional-tests.yml +++ b/.github/workflows/functional-tests.yml @@ -53,8 +53,8 @@ jobs: - name: Install dependencies working-directory: ${{ env.working-directory }} run: | - npm install - npm ci + npm install -g pnpm + pnpm i --frozen-lockfile - name: Install Playwright browser ${{ matrix.playwright-browser }} working-directory: ${{ env.working-directory }} run: npx playwright install --with-deps ${{ matrix.playwright-browser }} diff --git a/.github/workflows/startup-tests.yml b/.github/workflows/startup-tests.yml index ff341d1fc..727b43089 100644 --- a/.github/workflows/startup-tests.yml +++ b/.github/workflows/startup-tests.yml @@ -46,8 +46,8 @@ jobs: - name: Install dependencies working-directory: ${{ env.working-directory }} run: | - npm install - npm ci + npm install -g pnpm + pnpm i --frozen-lockfile - name: Install Playwright Browsers working-directory: ${{ env.working-directory }} run: npx playwright install --with-deps @@ -101,8 +101,8 @@ jobs: - name: Install dependencies working-directory: ${{ env.working-directory }} run: | - npm install - npm ci + npm install -g pnpm + pnpm i --frozen-lockfile - name: Install Playwright Browsers working-directory: ${{ env.working-directory }} run: npx playwright install --with-deps diff --git a/README.md b/README.md index 26533afd0..92db3baa3 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ The decoupling allows you to save a considerable amount of time: - leave the reporting formatting and sanity check to CISO assistant and focus on your fixes, - balance controls implementation and compliance follow-up +Here is an overview of CISO Assistant features and capabilities: + +![overview](features.png) + + CISO Assistant is developed and maintained by [intuitem](https://intuitem.com/), a French ๐ซ๐ท company specialized in Cyber Security, Cloud and Data/AI. ## Quick Start ๐ @@ -78,6 +83,9 @@ and run the starter script > [!WARNING] > If you're getting warnings or errors about image's platform not matching host platform, raise an issue with the details and we'll add it shortly after. You can also use `docker-compose-build.sh` instead (see below) to build for your specific architecture. +> [!CAUTION] +> Don't use the `main` branch code directly for production as it's the merge upstream and can have breaking changes during our developemnt. Either use the `tags` for stable versions or prebuilt images. + ## End-user Documentation Check out the online documentation on https://intuitem.gitbook.io/ciso-assistant. @@ -127,9 +135,17 @@ Check out the online documentation on https://intuitem.gitbook.io/ciso-assistant 41. ENISA: 5G Security Controls Matrix ๐ช๐บ 42. OWASP Mobile Application Security Verification Standard (MASVS) ๐๐ฑ 43. Agile Security Framework (ASF) - baseline - by intuitem ๐ค -44. EU AI Act ๐ช๐บ๐ค -45. FBI CJIS ๐บ๐ธ๐ฎ -46. Operational Technology Cybersecurity Controls (OTCC) ๐ธ๐ฆ +44. ISO 27001:2013 ๐ (For legacy and migration) +45. EU AI Act ๐ช๐บ๐ค +46. FBI CJIS ๐บ๐ธ๐ฎ +47. Operational Technology Cybersecurity Controls (OTCC) ๐ธ๐ฆ +48. Secure Controls Framework (SCF) ๐บ๐ธ๐ +49. NCSC Cyber Assessment Framework (CAF) ๐ฌ๐ง +50. California Consumer Privacy Act (CCPA) ๐บ๐ธ +51. California Consumer Privacy Act Regulations ๐บ๐ธ +52. NCSC Cyber Essentials ๐ฌ๐ง +53. General Data Protection Regulation (GDPR) ๐ช๐บ +54. Directive Nationale de la Sรฉcuritรฉ des Systรจmes d'Information (DNSSI) Maroc ๐ฒ๐ฆ ### Community contributions @@ -150,13 +166,11 @@ Checkout the [library](/backend/library/libraries/) and [tools](/tools/) for the ### Coming soon -- NCSC Cyber Assessment Framework (CAF) -- Secure Controls Framework (SCF) -- CCPA - Part-IS -- SOX - NIST 800-82 -- UK Cyber Essentials +- Korea ISA: ISMS-P +- ENS Esquema Nacional de seguridad (espaรฑol) + - and much more: just ask on [Discord](https://discord.gg/qvkaMdQ8da). If it's an open standard, we'll do it for you, _free of charge_ ๐ ## Add your own library @@ -167,6 +181,8 @@ Take a look at the `tools` directory and its dedicated readme. The `convert_libr You can also find some specific converters in the tools directory (e.g. for CIS or CCM Controls). +There is also a tool to facilitate the creation of mappings, called `prepare_mapping.py` that will create an Excel file based on two framework libraries in yaml. Once properly filled, this Excel file can be processed by the `convert_library.py` tool to get the resulting mapping library. + ## Community Join our [open Discord community](https://discord.gg/qvkaMdQ8da) to interact with the team and other GRC experts. diff --git a/backend/app_tests/api/test_api_compliance_assessments.py b/backend/app_tests/api/test_api_compliance_assessments.py index 406fb46b3..992fc5b76 100644 --- a/backend/app_tests/api/test_api_compliance_assessments.py +++ b/backend/app_tests/api/test_api_compliance_assessments.py @@ -120,6 +120,10 @@ def test_get_compliance_assessments(self, test): "project": { "id": str(project.id), "str": project.folder.name + "/" + project.name, + "folder": { + "id": str(project.folder.id), + "str": project.folder.name, + }, }, "framework": { "id": str(Framework.objects.all()[0].id), @@ -127,6 +131,7 @@ def test_get_compliance_assessments(self, test): "implementation_groups_definition": None, "min_score": 1, "max_score": 4, + "ref_id": str(Framework.objects.all()[0].ref_id), }, }, user_group=test.user_group, @@ -154,6 +159,10 @@ def test_create_compliance_assessments(self, test): "project": { "id": str(project.id), "str": project.folder.name + "/" + project.name, + "folder": { + "id": str(project.folder.id), + "str": project.folder.name, + }, }, "framework": { "id": str(Framework.objects.all()[0].id), @@ -161,6 +170,7 @@ def test_create_compliance_assessments(self, test): "implementation_groups_definition": None, "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), }, }, user_group=test.user_group, @@ -200,6 +210,10 @@ def test_update_compliance_assessments(self, test): "project": { "id": str(project.id), "str": project.folder.name + "/" + project.name, + "folder": { + "id": str(project.folder.id), + "str": project.folder.name, + }, }, "framework": { "id": str(Framework.objects.all()[0].id), @@ -207,6 +221,7 @@ def test_update_compliance_assessments(self, test): "implementation_groups_definition": None, "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), }, }, user_group=test.user_group, diff --git a/backend/app_tests/api/test_api_requirement_assessments.py b/backend/app_tests/api/test_api_requirement_assessments.py index 94523616c..ef49c83e1 100644 --- a/backend/app_tests/api/test_api_requirement_assessments.py +++ b/backend/app_tests/api/test_api_requirement_assessments.py @@ -13,8 +13,8 @@ from test_utils import EndpointTestsQueries # Generic requirement assessment data for tests -REQUIREMENT_ASSESSMENT_STATUS = "partially_compliant" -REQUIREMENT_ASSESSMENT_STATUS2 = "non_compliant" +REQUIREMENT_ASSESSMENT_STATUS = "to_do" +REQUIREMENT_ASSESSMENT_STATUS2 = "in_progress" REQUIREMENT_ASSESSMENT_OBSERVATION = "Test observation" diff --git a/backend/app_tests/api/test_api_risk_acceptances.py b/backend/app_tests/api/test_api_risk_acceptances.py index 56b6253c1..c0157057f 100644 --- a/backend/app_tests/api/test_api_risk_acceptances.py +++ b/backend/app_tests/api/test_api_risk_acceptances.py @@ -117,7 +117,12 @@ def test_get_risk_acceptances(self, test): }, { "folder": {"id": str(test.folder.id), "str": test.folder.name}, - "approver": {"id": str(approver.id), "str": approver.email}, + "approver": { + "id": str(approver.id), + "str": approver.email, + "last_name": approver.last_name, + "first_name": approver.first_name, + }, "state": RISK_ACCEPTANCE_STATE[1], }, user_group=test.user_group, @@ -157,7 +162,12 @@ def test_create_risk_acceptances(self, test): }, { "folder": {"id": str(test.folder.id), "str": test.folder.name}, - "approver": {"id": str(approver.id), "str": approver.email}, + "approver": { + "id": str(approver.id), + "str": approver.email, + "last_name": approver.last_name, + "first_name": approver.first_name, + }, "risk_scenarios": [ {"id": str(risk_scenario.id), "str": str(risk_scenario)} ], @@ -208,7 +218,12 @@ def test_update_risk_acceptances(self, test): }, { "folder": {"id": str(test.folder.id), "str": test.folder.name}, - "approver": {"id": str(approver.id), "str": approver.email}, + "approver": { + "id": str(approver.id), + "str": approver.email, + "last_name": approver.last_name, + "first_name": approver.first_name, + }, # 'state': RISK_ACCEPTANCE_STATE[1], }, user_group=test.user_group, diff --git a/backend/app_tests/api/test_api_risk_assessments.py b/backend/app_tests/api/test_api_risk_assessments.py index 84feefa51..4afcb6fd9 100644 --- a/backend/app_tests/api/test_api_risk_assessments.py +++ b/backend/app_tests/api/test_api_risk_assessments.py @@ -123,6 +123,10 @@ def test_get_risk_assessments(self, test): "project": { "id": str(project.id), "str": project.folder.name + "/" + project.name, + "folder": { + "id": str(project.folder.id), + "str": project.folder.name, + }, }, "risk_matrix": {"id": str(risk_matrix.id), "str": str(risk_matrix)}, }, @@ -152,6 +156,10 @@ def test_create_risk_assessments(self, test): "project": { "id": str(project.id), "str": project.folder.name + "/" + project.name, + "folder": { + "id": str(project.folder.id), + "str": project.folder.name, + }, }, "risk_matrix": {"id": str(risk_matrix.id), "str": str(risk_matrix)}, }, @@ -193,6 +201,10 @@ def test_update_risk_assessments(self, test): "project": { "id": str(project.id), "str": project.folder.name + "/" + project.name, + "folder": { + "id": str(project.folder.id), + "str": project.folder.name, + }, }, "risk_matrix": {"id": str(risk_matrix.id), "str": str(risk_matrix)}, }, diff --git a/backend/app_tests/api/test_api_risk_scenarios.py b/backend/app_tests/api/test_api_risk_scenarios.py index a402c41be..b848508e5 100644 --- a/backend/app_tests/api/test_api_risk_scenarios.py +++ b/backend/app_tests/api/test_api_risk_scenarios.py @@ -197,6 +197,7 @@ def test_get_risk_scenarios(self, test): "treatment": RISK_SCENARIO_TREATMENT_STATUS[1], "risk_assessment": { "id": str(risk_assessment.id), + "name": str(risk_assessment.name), "str": str(risk_assessment), }, "threats": [{"id": str(threat.id), "str": str(threat)}], @@ -256,6 +257,7 @@ def test_create_risk_scenarios(self, test): "risk_assessment": { "id": str(risk_assessment.id), "str": str(risk_assessment), + "name": str(risk_assessment.name), }, "threats": [{"id": str(threat.id), "str": threat.name}], "risk_matrix": { @@ -339,6 +341,7 @@ def test_update_risk_scenarios(self, test): "risk_assessment": { "id": str(risk_assessment.id), "str": str(risk_assessment), + "name": str(risk_assessment.name), }, "threats": [{"id": str(threat.id), "str": threat.name}], "risk_matrix": { diff --git a/backend/ciso_assistant/settings.py b/backend/ciso_assistant/settings.py index b12724739..9aa2f8676 100644 --- a/backend/ciso_assistant/settings.py +++ b/backend/ciso_assistant/settings.py @@ -196,6 +196,7 @@ def set_ciso_assistant_url(_, __, event_dict): "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "PAGE_SIZE": PAGINATE_BY, "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "EXCEPTION_HANDLER": "core.helpers.handle", } REST_KNOX = { diff --git a/backend/core/apps.py b/backend/core/apps.py index faae6b48b..20c023035 100644 --- a/backend/core/apps.py +++ b/backend/core/apps.py @@ -29,6 +29,8 @@ "view_loadedlibrary", "view_storedlibrary", "view_user", + "view_requirementmappingset", + "view_requirementmapping", ] APPROVER_PERMISSIONS_LIST = [ @@ -53,6 +55,8 @@ "view_storedlibrary", "view_loadedlibrary", "view_user", + "view_requirementmappingset", + "view_requirementmapping", ] ANALYST_PERMISSIONS_LIST = [ @@ -107,6 +111,8 @@ "view_storedlibrary", "view_loadedlibrary", "view_user", + "view_requirementmappingset", + "view_requirementmapping", ] DOMAIN_MANAGER_PERMISSIONS_LIST = [ @@ -166,6 +172,8 @@ "view_storedlibrary", "view_loadedlibrary", "view_user", + "view_requirementmappingset", + "view_requirementmapping", ] ADMINISTRATOR_PERMISSIONS_LIST = [ @@ -250,6 +258,8 @@ "restore", "view_globalsettings", "change_globalsettings", + "view_requirementmappingset", + "view_requirementmapping", ] diff --git a/backend/core/base_models.py b/backend/core/base_models.py index cef56cd72..264567870 100644 --- a/backend/core/base_models.py +++ b/backend/core/base_models.py @@ -116,3 +116,15 @@ class Meta: def __str__(self) -> str: return self.name + + +class ETADueDateMixin(models.Model): + """ + Mixin for models that have an ETA and a due date. + """ + + eta = models.DateField(null=True, blank=True, verbose_name=_("ETA")) + due_date = models.DateField(null=True, blank=True, verbose_name=_("Due date")) + + class Meta: + abstract = True diff --git a/backend/core/helpers.py b/backend/core/helpers.py index 4067653c3..831190718 100644 --- a/backend/core/helpers.py +++ b/backend/core/helpers.py @@ -12,6 +12,14 @@ from typing import List, Dict, Optional +from django.core.exceptions import NON_FIELD_ERRORS as DJ_NON_FIELD_ERRORS +from django.core.exceptions import ValidationError as DjValidationError +from rest_framework.exceptions import ValidationError as DRFValidationError +from rest_framework.views import api_settings +from rest_framework.views import exception_handler as drf_exception_handler + +DRF_NON_FIELD_ERRORS = api_settings.NON_FIELD_ERRORS_KEY + def flatten_dict( d: MutableMapping, parent_key: str = "", sep: str = "." @@ -46,9 +54,11 @@ def flatten_dict( def color_css_class(status): return { + "not_assessed": "gray-300", "compliant": "green-500", - "to_do": "gray-300", + "to_do": "gray-400", "in_progress": "blue-500", + "done": "green-500", "non_compliant": "red-500", "partially_compliant": "yellow-400", "not_applicable": "black", @@ -269,11 +279,16 @@ def get_sorted_requirement_nodes_rec(start: list) -> dict: "implementation_groups": node.implementation_groups or None, "ra_id": str(req_as.id) if req_as else None, "status": req_as.status if req_as else None, + "result": req_as.result if req_as else None, "is_scored": req_as.is_scored if req_as else None, "score": req_as.score if req_as else None, "max_score": max_score if req_as else None, + "mapping_inference": req_as.mapping_inference if req_as else None, "status_display": req_as.get_status_display() if req_as else None, "status_i18n": camel_case(req_as.status) if req_as else None, + "result_i18n": camel_case(req_as.result) + if req_as and req_as.result is not None + else None, "node_content": node.display_long, "style": "node", "assessable": node.assessable, @@ -305,12 +320,19 @@ def get_sorted_requirement_nodes_rec(start: list) -> dict: "is_scored": child_req_as.is_scored if child_req_as else None, "score": child_req_as.score if child_req_as else None, "max_score": max_score if child_req_as else None, + "mapping_inference": child_req_as.mapping_inference + if child_req_as + else None, "status_display": child_req_as.get_status_display() if child_req_as else None, "status_i18n": camel_case(child_req_as.status) if child_req_as else None, + "result": child_req_as.result if child_req_as else None, + "result_i18n": camel_case(child_req_as.result) + if child_req_as and child_req_as.result is not None + else None, "style": "leaf", } @@ -621,14 +643,14 @@ def aggregate_risks_per_field( .filter(residual_level=i) # .filter(risk_assessment__risk_matrix__name=["name"]) .count() - ) # What the second filter does ? Is this usefull ? + ) # What the second filter does ? Is this useful ? else: count = ( RiskScenario.objects.filter(id__in=object_ids_view) .filter(current_level=i) # .filter(risk_assessment__risk_matrix__name=["name"]) .count() - ) # What the second filter does ? Is this usefull ? + ) # What the second filter does ? Is this useful ? if "count" not in values[m["risk"][i][field]]: values[m["risk"][i][field]]["count"] = count @@ -984,3 +1006,17 @@ def threats_count_per_name(user: User): label["max"] = max_offset return {"labels": labels, "values": values} + + +def handle(exc, context): + # translate django validation error which ... + # .. causes HTTP 500 status ==> DRF validation which will cause 400 HTTP status + if isinstance(exc, DjValidationError): + data = exc.message_dict + if DJ_NON_FIELD_ERRORS in data: + data[DRF_NON_FIELD_ERRORS] = data[DJ_NON_FIELD_ERRORS] + del data[DJ_NON_FIELD_ERRORS] + + exc = DRFValidationError(detail=data) + + return drf_exception_handler(exc, context) diff --git a/backend/core/migrations/0015_remove_complianceassessment_result_and_more.py b/backend/core/migrations/0015_remove_complianceassessment_result_and_more.py new file mode 100644 index 000000000..af43874aa --- /dev/null +++ b/backend/core/migrations/0015_remove_complianceassessment_result_and_more.py @@ -0,0 +1,110 @@ +# Generated by Django 5.0.6 on 2024-06-26 10:11 + +from django.db import migrations, models + + +class Results(models.TextChoices): + NOT_ASSESSED = "not_assessed", "Not assessed" + PARTIALLY_COMPLIANT = "partially_compliant", "Partially compliant" + NON_COMPLIANT = "non_compliant", "Non-compliant" + COMPLIANT = "compliant", "Compliant" + NOT_APPLICABLE = "not_applicable", "Not applicable" + + +class Status(models.TextChoices): + TODO = "to_do", "To do" + IN_PROGRESS = "in_progress", "In progress" + IN_REVIEW = "in_review", "In review" + DONE = "done", "Done" + + +def create_result(apps, schema_editor): + RequirementAssessment = apps.get_model("core", "RequirementAssessment") + for assessment in RequirementAssessment.objects.all(): + if assessment.status in Results.values: + setattr(assessment, "result", assessment.status) + assessment.status = Status.TODO + if assessment.result == Results.COMPLIANT: + assessment.status = Status.DONE + if assessment.result == Results.PARTIALLY_COMPLIANT: + assessment.status = Status.IN_PROGRESS + if assessment.result == Results.NON_COMPLIANT: + assessment.status = Status.IN_REVIEW + if assessment.result == Results.NOT_APPLICABLE: + assessment.status = Status.DONE + assessment.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0014_auto_20240522_1731"), + ] + + operations = [ + migrations.RemoveField( + model_name="complianceassessment", + name="result", + ), + migrations.AddField( + model_name="requirementassessment", + name="due_date", + field=models.DateField(blank=True, null=True, verbose_name="Due date"), + ), + migrations.AddField( + model_name="requirementassessment", + name="eta", + field=models.DateField(blank=True, null=True, verbose_name="ETA"), + ), + migrations.AddField( + model_name="requirementassessment", + name="result", + field=models.CharField( + choices=[ + ("not_assessed", "Not assessed"), + ("partially_compliant", "Partially compliant"), + ("non_compliant", "Non-compliant"), + ("compliant", "Compliant"), + ("not_applicable", "Not applicable"), + ], + default="not_assessed", + max_length=64, + verbose_name="Result", + ), + ), + migrations.AlterField( + model_name="complianceassessment", + name="due_date", + field=models.DateField(blank=True, null=True, verbose_name="Due date"), + ), + migrations.AlterField( + model_name="complianceassessment", + name="eta", + field=models.DateField(blank=True, null=True, verbose_name="ETA"), + ), + migrations.AlterField( + model_name="requirementassessment", + name="status", + field=models.CharField( + choices=[ + ("to_do", "To do"), + ("in_progress", "In progress"), + ("in_review", "In review"), + ("done", "Done"), + ], + default="to_do", + max_length=100, + verbose_name="Status", + ), + ), + migrations.AlterField( + model_name="riskassessment", + name="due_date", + field=models.DateField(blank=True, null=True, verbose_name="Due date"), + ), + migrations.AlterField( + model_name="riskassessment", + name="eta", + field=models.DateField(blank=True, null=True, verbose_name="ETA"), + ), + migrations.RunPython(create_result), + ] diff --git a/backend/core/migrations/0016_riskscenario_owner.py b/backend/core/migrations/0016_riskscenario_owner.py new file mode 100644 index 000000000..91c9b41f1 --- /dev/null +++ b/backend/core/migrations/0016_riskscenario_owner.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.6 on 2024-07-06 13:27 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0015_remove_complianceassessment_result_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="riskscenario", + name="owner", + field=models.ManyToManyField( + blank=True, + related_name="risk_scenarios", + to=settings.AUTH_USER_MODEL, + verbose_name="Owner", + ), + ), + ] diff --git a/backend/core/migrations/0017_requirementassessment_mapping_inference_and_more.py b/backend/core/migrations/0017_requirementassessment_mapping_inference_and_more.py new file mode 100644 index 000000000..825a069c6 --- /dev/null +++ b/backend/core/migrations/0017_requirementassessment_mapping_inference_and_more.py @@ -0,0 +1,207 @@ +# Generated by Django 5.0.6 on 2024-07-08 07:04 + +import django.core.validators +import django.db.models.deletion +import iam.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0016_riskscenario_owner"), + ("iam", "0005_alter_user_managers"), + ] + + operations = [ + migrations.AddField( + model_name="requirementassessment", + name="mapping_inference", + field=models.JSONField(default=dict, verbose_name="Mapping inference"), + ), + migrations.CreateModel( + name="RequirementMappingSet", + 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"), + ), + ( + "urn", + models.CharField( + blank=True, + max_length=100, + null=True, + unique=True, + verbose_name="URN", + ), + ), + ( + "ref_id", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Reference ID", + ), + ), + ( + "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"), + ), + ( + "target_framework", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="target_framework", + to="core.framework", + verbose_name="Target framework", + ), + ), + ( + "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", + ), + ), + ( + "library", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="requirement_mapping_sets", + to="core.loadedlibrary", + ), + ), + ( + "source_framework", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="source_framework", + to="core.framework", + verbose_name="Source framework", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="RequirementMapping", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "relationship", + models.CharField( + choices=[ + ("subset", "Subset"), + ("intersect", "Intersect"), + ("equal", "Equal"), + ("superset", "Superset"), + ("not_related", "Not related"), + ], + default="not_related", + max_length=20, + verbose_name="Relationship", + ), + ), + ( + "rationale", + models.CharField( + blank=True, + choices=[ + ("syntactic", "Syntactic"), + ("semantic", "Semantic"), + ("functional", "Functional"), + ], + max_length=20, + null=True, + verbose_name="Rationale", + ), + ), + ( + "strength_of_relationship", + models.PositiveSmallIntegerField( + null=True, + validators=[django.core.validators.MaxValueValidator(10)], + verbose_name="Strength of relationship", + ), + ), + ( + "annotation", + models.TextField(blank=True, null=True, verbose_name="Annotation"), + ), + ( + "target_requirement", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="target_requirement", + to="core.requirementnode", + verbose_name="Target requirement", + ), + ), + ( + "source_requirement", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="source_requirement", + to="core.requirementnode", + verbose_name="Source requirement", + ), + ), + ( + "mapping_set", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="mappings", + to="core.requirementmappingset", + verbose_name="Mapping set", + ), + ), + ], + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index d4d4bbe02..f754812dc 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -1,12 +1,13 @@ from pathlib import Path from django.apps import apps +from django.core.validators import MaxValueValidator from django.forms.models import model_to_dict from django.contrib.auth import get_user_model from django.db import models, transaction from django.utils.translation import gettext_lazy as _ from django.db.models import Q -from .base_models import * +from .base_models import AbstractBaseModel, NameDescriptionMixin, ETADueDateMixin from .validators import validate_file_size, validate_file_name from .utils import camel_case, sha256 from iam.models import FolderMixin, PublishInRootFolderMixin @@ -16,6 +17,8 @@ import json import yaml +from django.core.exceptions import ValidationError + from django.urls import reverse from datetime import date, datetime from typing import Union, Dict, Set, List, Tuple, Type, Self @@ -30,7 +33,7 @@ ########################### Referential objects ######################### -class ReferentialObjectMixin(NameDescriptionMixin, FolderMixin): +class ReferentialObjectMixin(AbstractBaseModel, FolderMixin): """ Mixin for referential objects. """ @@ -41,10 +44,6 @@ class ReferentialObjectMixin(NameDescriptionMixin, FolderMixin): ref_id = models.CharField( max_length=100, blank=True, null=True, verbose_name=_("Reference ID") ) - locale = models.CharField( - max_length=100, null=False, blank=False, default="en", verbose_name=_("Locale") - ) - default_locale = models.BooleanField(default=True, verbose_name=_("Default locale")) provider = models.CharField( max_length=200, blank=True, null=True, verbose_name=_("Provider") ) @@ -85,7 +84,17 @@ def __str__(self) -> str: return self.display_short -class LibraryMixin(ReferentialObjectMixin): +class I18nObjectMixin(models.Model): + locale = models.CharField( + max_length=100, null=False, blank=False, default="en", verbose_name=_("Locale") + ) + default_locale = models.BooleanField(default=True, verbose_name=_("Default locale")) + + class Meta: + abstract = True + + +class LibraryMixin(ReferentialObjectMixin, I18nObjectMixin): class Meta: abstract = True unique_together = [["urn", "locale", "version"]] @@ -170,7 +179,7 @@ def store_library_content( outdated_library.delete() objects_meta = { - key: (1 if key == "framework" else len(value)) + key: (1 if key == "framework" or "requirement_mapping_set" else len(value)) for key, value in library_data["objects"].items() } @@ -408,16 +417,17 @@ def update_library(self) -> Union[str, None]: requirement_node_dict["order_id"] = order_id order_id += 1 - new_requirement_node, created = ( - RequirementNode.objects.update_or_create( - urn=requirement_node["urn"].lower(), - defaults=requirement_node_dict, - create_defaults={ - **referential_object_dict, - **requirement_node_dict, - "framework": new_framework, - }, - ) + ( + new_requirement_node, + created, + ) = RequirementNode.objects.update_or_create( + urn=requirement_node["urn"].lower(), + defaults=requirement_node_dict, + create_defaults={ + **referential_object_dict, + **requirement_node_dict, + "framework": new_framework, + }, ) if created: @@ -445,7 +455,7 @@ def update_library(self) -> Union[str, None]: ) if ( reference_control_to_add is None - ): # I am not 100% this condition is usefull + ): # I am not 100% this condition is useful reference_control_to_add = ReferenceControl.objects.filter( urn=reference_control_urn.lower() ).first() # No locale support @@ -570,7 +580,7 @@ def delete(self, *args, **kwargs): ) -class Threat(ReferentialObjectMixin, PublishInRootFolderMixin): +class Threat(ReferentialObjectMixin, I18nObjectMixin, PublishInRootFolderMixin): library = models.ForeignKey( LoadedLibrary, on_delete=models.CASCADE, @@ -601,7 +611,7 @@ def __str__(self): return self.name -class ReferenceControl(ReferentialObjectMixin): +class ReferenceControl(ReferentialObjectMixin, I18nObjectMixin): CATEGORY = [ ("policy", _("Policy")), ("process", _("Process")), @@ -658,7 +668,7 @@ def __str__(self): ) -class RiskMatrix(ReferentialObjectMixin): +class RiskMatrix(ReferentialObjectMixin, I18nObjectMixin): library = models.ForeignKey( LoadedLibrary, on_delete=models.CASCADE, @@ -740,7 +750,7 @@ def __str__(self) -> str: return self.name -class Framework(ReferentialObjectMixin): +class Framework(ReferentialObjectMixin, I18nObjectMixin): min_score = models.IntegerField(default=0, verbose_name=_("Minimum score")) max_score = models.IntegerField(default=100, verbose_name=_("Maximum score")) scores_definition = models.JSONField( @@ -803,7 +813,7 @@ def process_node(self, node): return node_dict -class RequirementNode(ReferentialObjectMixin): +class RequirementNode(ReferentialObjectMixin, I18nObjectMixin): threats = models.ManyToManyField( "Threat", blank=True, @@ -841,6 +851,109 @@ class Meta: verbose_name_plural = _("RequirementNodes") +class RequirementMappingSet(ReferentialObjectMixin): + library = models.ForeignKey( + LoadedLibrary, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="requirement_mapping_sets", + ) + + source_framework = models.ForeignKey( + Framework, + on_delete=models.CASCADE, + verbose_name=_("Source framework"), + related_name="source_framework", + ) + target_framework = models.ForeignKey( + Framework, + on_delete=models.CASCADE, + verbose_name=_("Target framework"), + related_name="target_framework", + ) + + def save(self, *args, **kwargs) -> None: + if self.source_framework == self.target_framework: + raise ValidationError(_("Source and related frameworks must be different")) + return super().save(*args, **kwargs) + + +class RequirementMapping(models.Model): + class Coverage(models.TextChoices): + FULL = "full", _("Full") + PARTIAL = "partial", _("Partial") + NOT_RELATED = "not_related", _("Not related") + + class Relationship(models.TextChoices): + SUBSET = "subset", _("Subset") + INTERSECT = "intersect", _("Intersect") + EQUAL = "equal", _("Equal") + SUPERSET = "superset", _("Superset") + NOT_RELATED = "not_related", _("Not related") + + class Rationale(models.TextChoices): + SYNTACTIC = "syntactic", _("Syntactic") + SEMANTIC = "semantic", _("Semantic") + FUNCTIONAL = "functional", _("Functional") + + FULL_COVERAGE_RELATIONSHIPS = [ + Relationship.EQUAL, + Relationship.SUPERSET, + ] + + PARTIAL_COVERAGE_RELATIONSHIPS = [ + Relationship.INTERSECT, + Relationship.SUBSET, + ] + + mapping_set = models.ForeignKey( + RequirementMappingSet, + on_delete=models.CASCADE, + verbose_name=_("Mapping set"), + related_name="mappings", + ) + target_requirement = models.ForeignKey( + RequirementNode, + on_delete=models.CASCADE, + verbose_name=_("Target requirement"), + related_name="target_requirement", + ) + relationship = models.CharField( + max_length=20, + choices=Relationship.choices, + default=Relationship.NOT_RELATED, + verbose_name=_("Relationship"), + ) + rationale = models.CharField( + max_length=20, + null=True, + blank=True, + choices=Rationale.choices, + verbose_name=_("Rationale"), + ) + source_requirement = models.ForeignKey( + RequirementNode, + on_delete=models.CASCADE, + verbose_name=_("Source requirement"), + related_name="source_requirement", + ) + strength_of_relationship = models.PositiveSmallIntegerField( + null=True, + verbose_name=_("Strength of relationship"), + validators=[MaxValueValidator(10)], + ) + annotation = models.TextField(null=True, blank=True, verbose_name=_("Annotation")) + + @property + def coverage(self) -> str: + if self.relationship == RequirementMapping.Relationship.NOT_RELATED: + return RequirementMapping.Coverage.NOT_RELATED + if self.relationship in self.FULL_COVERAGE_RELATIONSHIPS: + return RequirementMapping.Coverage.FULL + return RequirementMapping.Coverage.PARTIAL + + ########################### Domain objects ######################### @@ -1146,7 +1259,7 @@ def save(self, *args, **kwargs): ########################### Secondary objects ######################### -class Assessment(NameDescriptionMixin): +class Assessment(NameDescriptionMixin, ETADueDateMixin): class Status(models.TextChoices): PLANNED = "planned", _("Planned") IN_PROGRESS = "in_progress", _("In progress") @@ -1185,18 +1298,6 @@ class Status(models.TextChoices): verbose_name=_("Reviewers"), related_name="%(class)s_reviewers", ) - eta = models.DateField( - null=True, - blank=True, - help_text=_("Estimated time of arrival"), - verbose_name=_("ETA"), - ) - due_date = models.DateField( - null=True, - blank=True, - help_text=_("Due date"), - verbose_name=_("Due date"), - ) fields_to_check = ["name", "version"] @@ -1217,7 +1318,7 @@ class Meta: verbose_name_plural = _("Risk assessments") def __str__(self) -> str: - return f"{self.project}/{self.name} - {self.version}" + return f"{self.name} - {self.version}" @property def path_display(self) -> str: @@ -1563,6 +1664,12 @@ class RiskScenario(NameDescriptionMixin): blank=True, ) + owner = models.ManyToManyField( + User, + blank=True, + verbose_name=_("Owner"), + related_name="risk_scenarios", + ) # current current_proba = models.SmallIntegerField( default=-1, verbose_name=_("Current probability") @@ -1707,22 +1814,9 @@ def save(self, *args, **kwargs): class ComplianceAssessment(Assessment): - class Result(models.TextChoices): - COMPLIANT = "compliant", _("Compliant") - NON_COMPLIANT_MINOR = "non_compliant_minor", _("Non compliant (minor)") - NON_COMPLIANT_MAJOR = "non_compliant_major", _("Non compliant (major)") - NOT_APPLICABLE = "not_applicable", _("Not applicable") - framework = models.ForeignKey( Framework, on_delete=models.CASCADE, verbose_name=_("Framework") ) - result = models.CharField( - blank=True, - null=True, - max_length=100, - choices=Result.choices, - verbose_name=_("Result"), - ) selected_implementation_groups = models.JSONField( blank=True, null=True, verbose_name=_("Selected implementation groups") ) @@ -1748,8 +1842,9 @@ def get_global_score(self): requirement_assessments_scored = ( RequirementAssessment.objects.filter(compliance_assessment=self) .exclude(score=None) - .exclude(status=RequirementAssessment.Status.NOT_APPLICABLE) + .exclude(status=RequirementAssessment.Result.NOT_APPLICABLE) .exclude(is_scored=False) + .exclude(requirement__assessable=False) ) ig = ( set(self.selected_implementation_groups) @@ -1849,18 +1944,57 @@ def union_queries(base_query, groups, field_name): return queries[0].union(*queries[1:]) if queries else base_query.none() color_map = { - "in_progress": "#3b82f6", - "non_compliant": "#f87171", - "to_do": "#d1d5db", - "partially_compliant": "#fde047", - "not_applicable": "#000000", - "compliant": "#86efac", + RequirementAssessment.Result.NOT_ASSESSED: "#d1d5db", + RequirementAssessment.Result.NON_COMPLIANT: "#f87171", + RequirementAssessment.Result.PARTIALLY_COMPLIANT: "#fde047", + RequirementAssessment.Result.COMPLIANT: "#86efac", + RequirementAssessment.Result.NOT_APPLICABLE: "#000000", + RequirementAssessment.Status.TODO: "#9ca3af", + RequirementAssessment.Status.IN_PROGRESS: "#f59e0b", + RequirementAssessment.Status.IN_REVIEW: "#3b82f6", + RequirementAssessment.Status.DONE: "#86efac", } + compliance_assessments_result = {"values": [], "labels": []} + for result in RequirementAssessment.Result.values: + assessable_requirements_filter = { + "compliance_assessment": self, + "requirement__assessable": True, + } + + base_query = RequirementAssessment.objects.filter( + result=result, **assessable_requirements_filter + ).distinct() + + if self.selected_implementation_groups: + union_query = union_queries( + base_query, + self.selected_implementation_groups, + "requirement__implementation_groups", + ) + else: + union_query = base_query + + count = union_query.count() + value_entry = { + "name": result, + "localName": camel_case(result), + "value": count, + "itemStyle": {"color": color_map[result]}, + } + + compliance_assessments_result["values"].append(value_entry) + compliance_assessments_result["labels"].append(result) + compliance_assessments_status = {"values": [], "labels": []} - for status in RequirementAssessment.Status: + for status in RequirementAssessment.Status.values: + assessable_requirements_filter = { + "compliance_assessment": self, + "requirement__assessable": True, + } + base_query = RequirementAssessment.objects.filter( - status=status, compliance_assessment=self, requirement__assessable=True + status=status, **assessable_requirements_filter ).distinct() if self.selected_implementation_groups: @@ -1875,15 +2009,18 @@ def union_queries(base_query, groups, field_name): count = union_query.count() value_entry = { "name": status, - "localName": camel_case(status.value), + "localName": camel_case(status), "value": count, "itemStyle": {"color": color_map[status]}, } compliance_assessments_status["values"].append(value_entry) - compliance_assessments_status["labels"].append(status.label) + compliance_assessments_status["labels"].append(status) - return compliance_assessments_status + return { + "result": compliance_assessments_result, + "status": compliance_assessments_status, + } def quality_check(self) -> dict: AppliedControl = apps.get_model("core", "AppliedControl") @@ -2006,13 +2143,77 @@ def quality_check(self) -> dict: } return findings + def compute_requirement_assessments_results( + self, mapping_set: RequirementMappingSet, source_assessment: Self + ) -> list["RequirementAssessment"]: + requirement_assessments: list[RequirementAssessment] = [] + result_order = ( + RequirementAssessment.Result.NON_COMPLIANT, + RequirementAssessment.Result.PARTIALLY_COMPLIANT, + RequirementAssessment.Result.COMPLIANT, + ) + for requirement_assessment in self.requirement_assessments.all(): + mappings = mapping_set.mappings.filter( + target_requirement=requirement_assessment.requirement + ) + inferences = [] + refs = [] + if mappings.filter( + relationship__in=RequirementMapping.FULL_COVERAGE_RELATIONSHIPS + ).exists(): + mappings = mappings.filter( + relationship__in=RequirementMapping.FULL_COVERAGE_RELATIONSHIPS + ) + for mapping in mappings: + source_requirement_assessment = RequirementAssessment.objects.get( + compliance_assessment=source_assessment, + requirement=mapping.source_requirement, + ) + inferred_result, inferred_status = requirement_assessment.infer_result( + mapping=mapping, + source_requirement_assessment=source_requirement_assessment, + ) + if inferred_result in result_order: + inferences.append((inferred_result, inferred_status)) + refs.append(source_requirement_assessment) + if inferences: + if len(inferences) == 1: + requirement_assessment.result = inferences[0][0] + if inferences[0][1]: + requirement_assessment.status = inferences[0][1] + ref = refs[0] + else: + lowest_result = min( + inferences, key=lambda x: result_order.index(x[0]) + ) + requirement_assessment.result = lowest_result[0] + if lowest_result[1]: + requirement_assessment.status = lowest_result[1] + ref = refs[inferences.index(lowest_result)] + requirement_assessment.mapping_inference = { + "result": requirement_assessment.result, + "source_requirement_assessment": { + "str": str(ref), + "id": str(ref.id), + "coverage": mapping.coverage, + }, + # "mappings": [mapping.id for mapping in mappings], + } + requirement_assessments.append(requirement_assessment) + return requirement_assessments + -class RequirementAssessment(AbstractBaseModel, FolderMixin): +class RequirementAssessment(AbstractBaseModel, FolderMixin, ETADueDateMixin): class Status(models.TextChoices): TODO = "to_do", _("To do") IN_PROGRESS = "in_progress", _("In progress") - NON_COMPLIANT = "non_compliant", _("Non compliant") + IN_REVIEW = "in_review", _("In review") + DONE = "done", _("Done") + + class Result(models.TextChoices): + NOT_ASSESSED = "not_assessed", _("Not assessed") PARTIALLY_COMPLIANT = "partially_compliant", _("Partially compliant") + NON_COMPLIANT = "non_compliant", _("Non-compliant") COMPLIANT = "compliant", _("Compliant") NOT_APPLICABLE = "not_applicable", _("Not applicable") @@ -2022,6 +2223,12 @@ class Status(models.TextChoices): default=Status.TODO, verbose_name=_("Status"), ) + result = models.CharField( + max_length=64, + choices=Result.choices, + verbose_name=_("Result"), + default=Result.NOT_ASSESSED, + ) score = models.IntegerField( blank=True, null=True, @@ -2057,6 +2264,10 @@ class Status(models.TextChoices): default=True, verbose_name=_("Selected"), ) + mapping_inference = models.JSONField( + default=dict, + verbose_name=_("Mapping inference"), + ) def __str__(self) -> str: return self.requirement.display_short @@ -2064,6 +2275,27 @@ def __str__(self) -> str: def get_requirement_description(self) -> str: return self.requirement.description + def infer_result( + self, mapping: RequirementMapping, source_requirement_assessment: Self + ) -> str | None: + if mapping.coverage == RequirementMapping.Coverage.FULL: + return ( + source_requirement_assessment.result, + source_requirement_assessment.status, + ) + if mapping.coverage == RequirementMapping.Coverage.PARTIAL: + if source_requirement_assessment.result in ( + RequirementAssessment.Result.COMPLIANT, + RequirementAssessment.Result.PARTIALLY_COMPLIANT, + ): + return (RequirementAssessment.Result.PARTIALLY_COMPLIANT, None) + if ( + source_requirement_assessment.result + == RequirementAssessment.Result.NON_COMPLIANT + ): + return (RequirementAssessment.Result.NON_COMPLIANT, None) + return (None, None) + class Meta: verbose_name = _("Requirement assessment") verbose_name_plural = _("Requirement assessments") diff --git a/backend/core/serializers.py b/backend/core/serializers.py index 5b3cf13d1..5ac179ff7 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -99,9 +99,8 @@ class Meta: class RiskAcceptanceReadSerializer(BaseModelSerializer): folder = FieldsRelatedField() - approver = FieldsRelatedField() risk_scenarios = FieldsRelatedField(many=True) - + approver = FieldsRelatedField(["id", "first_name", "last_name"]) state = serializers.CharField(source="get_state_display") class Meta: @@ -130,7 +129,15 @@ class Meta: exclude = ["created_at", "updated_at"] +class RiskAssessmentDuplicateSerializer(BaseModelSerializer): + class Meta: + model = RiskAssessment + fields = ["name", "version", "project", "description"] + + class RiskAssessmentReadSerializer(AssessmentReadSerializer): + str = serializers.CharField(source="__str__") + project = FieldsRelatedField(["id", "folder"]) risk_scenarios = FieldsRelatedField(many=True) risk_scenarios_count = serializers.IntegerField(source="risk_scenarios.count") risk_matrix = FieldsRelatedField() @@ -205,7 +212,7 @@ class Meta: class RiskScenarioReadSerializer(RiskScenarioWriteSerializer): - risk_assessment = FieldsRelatedField() + risk_assessment = FieldsRelatedField(["id", "name"]) risk_matrix = FieldsRelatedField(source="risk_assessment.risk_matrix") project = FieldsRelatedField( source="risk_assessment.project", fields=["id", "name", "folder"] @@ -228,6 +235,8 @@ class RiskScenarioReadSerializer(RiskScenarioWriteSerializer): applied_controls = FieldsRelatedField(many=True) rid = serializers.CharField() + owner = FieldsRelatedField(many=True) + class AppliedControlWriteSerializer(BaseModelSerializer): class Meta: @@ -468,8 +477,9 @@ class Meta: class ComplianceAssessmentReadSerializer(AssessmentReadSerializer): + project = FieldsRelatedField(["id", "folder"]) framework = FieldsRelatedField( - ["id", "min_score", "max_score", "implementation_groups_definition"] + ["id", "min_score", "max_score", "implementation_groups_definition", "ref_id"] ) selected_implementation_groups = serializers.ReadOnlyField( source="get_selected_implementation_groups" @@ -481,6 +491,16 @@ class Meta: class ComplianceAssessmentWriteSerializer(BaseModelSerializer): + baseline = serializers.PrimaryKeyRelatedField( + write_only=True, + queryset=ComplianceAssessment.objects.all(), + required=False, + allow_null=True, + ) + + def create(self, validated_data: Any): + return super().create(validated_data) + class Meta: model = ComplianceAssessment fields = "__all__" @@ -532,3 +552,27 @@ def get_compliance_assessment(self): class Meta: model = RequirementAssessment fields = "__all__" + + +class RequirementMappingSetReadSerializer(BaseModelSerializer): + source_framework = FieldsRelatedField() + target_framework = FieldsRelatedField() + library = FieldsRelatedField(["name", "urn"]) + folder = FieldsRelatedField() + + class Meta: + model = RequirementMappingSet + fields = "__all__" + + +class RequirementMappingSetWriteSerializer(RequirementMappingSetReadSerializer): + pass + + +class ComputeMappingSerializer(serializers.Serializer): + mapping_set = serializers.PrimaryKeyRelatedField( + queryset=RequirementMappingSet.objects.all() + ) + source_assessment = serializers.PrimaryKeyRelatedField( + queryset=ComplianceAssessment.objects.all() + ) diff --git a/backend/core/templates/core/audit_report.html b/backend/core/templates/core/audit_report.html index 4a341a372..5f8109d34 100644 --- a/backend/core/templates/core/audit_report.html +++ b/backend/core/templates/core/audit_report.html @@ -17,6 +17,9 @@
{% trans "Score:" %}
{{ compliance_assessment.get_global_score|floatformat }} + {% endif %} {% bar_graph assessments ancestors %}{% trans "Observation:" %}
{{ node.assessments.observation }}
- You cannot score if the requirement assessment is not applicable + {m.notApplicableScore()}
{/if}{m.complianceAssessment()}
@@ -221,9 +307,17 @@ {m.actionPlan()} - {m.flashMode()}Power-ups: + {m.flashMode()} +{m.mappingInferenceTip()}
+{description}
+ {/if} + + {:else} + + {#if title} + {title} + {/if} + {#if description} +{description}
+ {/if} + + {/if} + + {:else} +{#if title} {title} {/if} {#if description}
{description}
{/if} - - - {:else} -- {#if title} - {title} - {#if assessableNodes.length > 0} - - {assessableNodes.length} +
+ {/if} +{description}
- {/if} - - {/if} - + {/each} + {/if} +{title}
+
{#if title}
{title}
- {#if assessableNodes.length > 1}
+ {#if assessableNodes.length > 1 || (!assessable && assessableNodes.length > 0)}
{assessableNodes.length}
diff --git a/frontend/src/routes/(app)/libraries/[id=urn]/+page.server.ts b/frontend/src/routes/(app)/libraries/[id=urn]/+page.server.ts
index 64f9bbfd0..ae1975678 100644
--- a/frontend/src/routes/(app)/libraries/[id=urn]/+page.server.ts
+++ b/frontend/src/routes/(app)/libraries/[id=urn]/+page.server.ts
@@ -7,7 +7,7 @@ import { languageTag } from '$paraglide/runtime';
export const actions: Actions = {
load: async (event) => {
- const endpoint = `${BASE_API_URL}/stored-libraries/${event.params.id}/import`;
+ const endpoint = `${BASE_API_URL}/stored-libraries/${event.params.id}/import/`;
const res = await event.fetch(endpoint); // We will have to make this a POST later (we should use POST when creating a new object)
if (!res.ok) {
const response = await res.json();
diff --git a/frontend/src/routes/(app)/requirement-assessments/[id=uuid]/+page.server.ts b/frontend/src/routes/(app)/requirement-assessments/[id=uuid]/+page.server.ts
index 5bd1f8e69..acb0fb1c7 100644
--- a/frontend/src/routes/(app)/requirement-assessments/[id=uuid]/+page.server.ts
+++ b/frontend/src/routes/(app)/requirement-assessments/[id=uuid]/+page.server.ts
@@ -1,28 +1,15 @@
+import type { PageServerLoad } from './$types';
+
import { BASE_API_URL } from '$lib/utils/constants';
-import { getModelInfo, urlParamModelVerboseName } from '$lib/utils/crud';
-import { localItems, toCamelCase } from '$lib/utils/locales';
-import { modelSchema } from '$lib/utils/schemas';
+import { tableSourceMapper, type TableSource } from '@skeletonlabs/skeleton';
import { listViewFields } from '$lib/utils/table';
import type { urlModel } from '$lib/utils/types';
-import * as m from '$paraglide/messages';
-import { languageTag } from '$paraglide/runtime';
-import { tableSourceMapper, type TableSource } from '@skeletonlabs/skeleton';
-import type { Actions } from '@sveltejs/kit';
-import { getSecureRedirect } from '$lib/utils/helpers';
-import { fail, redirect } from '@sveltejs/kit';
-import { setFlash } from 'sveltekit-flash-message/server';
-import { setError, superValidate } from 'sveltekit-superforms';
-import { zod } from 'sveltekit-superforms/adapters';
-import type { PageServerLoad } from './$types';
export const load = (async ({ fetch, params }) => {
const URLModel = 'requirement-assessments';
- const endpoint = `${BASE_API_URL}/${URLModel}/${params.id}/`;
-
- const res = await fetch(endpoint);
- const requirementAssessment = await res.json();
-
- const compliance_assessment_score = await fetch(
+ const baseEndpoint = `${BASE_API_URL}/${URLModel}/${params.id}/`;
+ const requirementAssessment = await fetch(baseEndpoint).then((res) => res.json());
+ const complianceAssessmentScore = await fetch(
`${BASE_API_URL}/compliance-assessments/${requirementAssessment.compliance_assessment.id}/global_score/`
).then((res) => res.json());
const requirement = await fetch(
@@ -33,87 +20,6 @@ export const load = (async ({ fetch, params }) => {
.then((res) => res.json())
.then((res) => res.results[0]);
- const model = getModelInfo(URLModel);
-
- const object = { ...requirementAssessment };
- for (const key in object) {
- if (object[key] instanceof Object && 'id' in object[key]) {
- object[key] = object[key].id;
- }
- }
-
- const schema = modelSchema(URLModel);
- const form = await superValidate(object, zod(schema), { errors: true });
-
- const foreignKeys: Record
๐ {data.requirement.description}
-
- {m.suggestedReferenceControls()}
- {func.str}
+
+ {m.suggestedReferenceControls()}
+ {func.str}
+
+ {m.threatsCovered()}
+ {threat.str}
-
- {m.threatsCovered()}
- {threat.str}
+
+ {m.annotation()}
+
+ {annotation}
+
-
- {m.annotation()}
-
- {annotation}
-
+
+ {m.mappingInference()}
+
+
+ {mappingInference.sourceRequirementAssessment.str}
+
+
+ {m.coverageColon()}
+
+ {m[mappingInference.sourceRequirementAssessment.coverage]()}
+
+
+ {m.suggestionColon()}
+
+ {m[toCamelCase(mappingInference.result)]()}
+
+
+ {m.annotationColon()}
+ {mappingInference.annotation}
+ {m.requirementAppliedControlHelpText()} {m.requirementEvidenceHelpText()} {m.requirementAppliedControlHelpText()} {m.requirementEvidenceHelpText()}
+ ๐ {data.requirement.description}
+
+
+ {m.suggestedReferenceControls()}
+ {func.str}
+
+ {m.threatsCovered()}
+ {threat.str}
+
+ {m.annotation()}
+
+ {annotation}
+
+
+ {m.mappingInference()}
+
+
+ {mappingInference.sourceRequirementAssessment.str}
+
+
+ {m.coverageColon()}
+
+ {m[mappingInference.sourceRequirementAssessment.coverage]()}
+
+
+ {m.suggestionColon()}
+
+ {m[toCamelCase(mappingInference.result)]()}
+
+
+ {m.annotationColon()}
+ {mappingInference.annotation}
+ {m.requirementAppliedControlHelpText()} {m.requirementEvidenceHelpText()} {m.riskAssessment()} {m.treatmentPlan()} {m.riskAssessment()} {m.treatmentPlan()} {m.lastUpdate()}
{new Date(data.scenario.updated_at).toLocaleString(languageTag())}
{m.treatmentStatus()}
{localItems()[toCamelCase(data.scenario.treatment)]}
{m.appliedControls()}
diff --git a/frontend/src/routes/(app)/risk-scenarios/[id=uuid]/edit/+page.svelte b/frontend/src/routes/(app)/risk-scenarios/[id=uuid]/edit/+page.svelte
index 128a96bbe..3cff1e09f 100644
--- a/frontend/src/routes/(app)/risk-scenarios/[id=uuid]/edit/+page.svelte
+++ b/frontend/src/routes/(app)/risk-scenarios/[id=uuid]/edit/+page.svelte
@@ -133,7 +133,7 @@
{...$$restProps}
>
{m.lastUpdate()}
{new Date(data.scenario.updated_at).toLocaleString(languageTag())}
{m.setTemporaryPassword1()}
{m.setTemporaryPassword()} {
+ debugClicked = !debugClicked;
+ goto(`${$page.url.pathname}/set-password`)
+ .then((res) => {
+ debugText = `[THEN] ${res}`;
+ })
+ .catch((err) => {
+ debugText = `[CATCH] ${err}`;
+ });
+ }}>{m.setTemporaryPassword()}. {m.setTemporaryPassword2()}.
{data.requirement.urn}
+ {data.requirement.urn}
+
+ {m[data.requirementAssessment.status]()}
+
+
+ {m[data.requirementAssessment.result]()}
+
+ {#if data.requirementAssessment.is_scored}
+
- {#each reference_controls as func}
-
- {/if}
+ {#if has_threats || has_reference_controls || annotation || mappingInference.result}
+
+
+ {#if !hideSuggestion}
+ {#if has_threats || has_reference_controls}
+
+ {#each reference_controls as func}
+
+ {/if}
+
+ {#each threats as threat}
+
+ {/if}
+
- {#each threats as threat}
-
- {/if}
+ {/if}
+ {#if annotation}
+
+
+ {m.observation()}
+ {data.requirementAssessment.observation}
+ {data.requirement.urn}
+ {#if data.requirement.description}
+
+
+ {#if !hideSuggestion}
+ {#if has_threats || has_reference_controls}
+
+ {#each reference_controls as func}
+
+ {/if}
+
+ {#each threats as threat}
+
+ {/if}
+
+
+ {m.scope()}
{m.status()}
+ {#each data.scenario.owner as owner}
+
+ {m.threats()}
- {m.assets()}
- {m.residualRisk()}
{m.scope()}
{m.status()}
{m.targetAssessment()}
diff --git a/frontend/src/routes/(app)/scoring-assistant/selector.svelte b/frontend/src/routes/(app)/scoring-assistant/selector.svelte
index f6d48c040..f7869385a 100644
--- a/frontend/src/routes/(app)/scoring-assistant/selector.svelte
+++ b/frontend/src/routes/(app)/scoring-assistant/selector.svelte
@@ -18,7 +18,8 @@
diff --git a/frontend/src/routes/(app)/scoring-assistant/utils.ts b/frontend/src/routes/(app)/scoring-assistant/utils.ts
index 7a3cc25d0..bdd05a089 100644
--- a/frontend/src/routes/(app)/scoring-assistant/utils.ts
+++ b/frontend/src/routes/(app)/scoring-assistant/utils.ts
@@ -65,7 +65,7 @@ export const forms = {
]
}
],
- business_impact: [
+ vulnerability: [
{
id: 'ease_of_discovery',
text: 'easeOfDiscoveryText',
@@ -131,7 +131,7 @@ export const forms = {
]
}
],
- vulnerability: [
+ business_impact: [
{
id: 'financial_damage',
text: 'financialDamageText',
diff --git a/frontend/src/routes/(app)/users/[id=uuid]/edit/+page.svelte b/frontend/src/routes/(app)/users/[id=uuid]/edit/+page.svelte
index 7a33a408a..f8f5229b2 100644
--- a/frontend/src/routes/(app)/users/[id=uuid]/edit/+page.svelte
+++ b/frontend/src/routes/(app)/users/[id=uuid]/edit/+page.svelte
@@ -6,21 +6,43 @@
import { breadcrumbObject } from '$lib/utils/stores';
import * as m from '$paraglide/messages';
+ import { goto } from '$app/navigation';
export let data: PageData;
breadcrumbObject.set(data.object);
+
+ let debugStyle = '';
+ let debugClicked = false;
+ $: if (debugClicked) {
+ debugStyle = 'border: 50px solid violet;';
+ } else {
+ debugStyle = '';
+ }
+ let debugCodeElem = null;
+ let debugText = '...';
+{debugText}